The most underestimated cost of a MySQL-to-PostgreSQL migration is not the schema conversion or the application rewrite. It is the loss of operational intuition. A MySQL DBA who has run InnoDB in production for years carries a mental dashboard: what Threads_running looks like on a good day, what buffer pool hit rate means for this workload, when history list length says a purge problem is coming. After the migration, every one of those instincts points at a metric that no longer exists.
I have operated both systems, and the good news is that almost every MySQL signal has a PostgreSQL counterpart. They are rarely one-to-one, because the architectures differ in real ways, but the underlying questions are the same: how busy is the database, is the cache working, is old row cleanup keeping up, which queries hurt, and how far behind are the replicas.
This article is the translation table I wish someone had handed me. For each MySQL metric family, I will show what it told you, what the PostgreSQL equivalent is, where the mapping is imperfect, and what to put on the rebuilt dashboard.
Threads_running becomes active backends
On MySQL, SHOW GLOBAL STATUS gives you Threads_connected and Threads_running, and most seasoned operators watch the second one. Threads_running is the count of connections actually executing work right now, and a sustained spike is the classic early-warning signal that something is stacking up.
PostgreSQL has no thread pool to inspect because every connection is an operating system process, but the equivalent signal lives in pg_stat_activity. A backend with state equal to active is doing work; everything else is connected but waiting on the client.
SELECT state,
count(*) AS backends
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
GROUP BY state
ORDER BY backends DESC;
The translation: Threads_connected maps to total rows in pg_stat_activity, Threads_running maps to backends in the active state. There is one addition MySQL never forced you to watch as closely: the idle in transaction state. An idle-in-transaction session holds a snapshot open, which blocks vacuum from removing dead rows. On MySQL, a similar open transaction inflated the history list; on PostgreSQL it inflates dead tuples across the whole cluster. Put idle-in-transaction count and duration on the dashboard from day one.
Buffer pool hit rate becomes cache hit ratio
InnoDB operators derive buffer pool efficiency from Innodb_buffer_pool_read_requests versus Innodb_buffer_pool_reads: how often a logical read had to touch disk. PostgreSQL exposes the same idea through pg_stat_database, where blks_hit counts reads served from shared_buffers and blks_read counts reads that went past it.
SELECT datname,
blks_hit,
blks_read,
round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2) AS cache_hit_pct
FROM pg_stat_database
WHERE datname NOT LIKE 'template%'
ORDER BY blks_read DESC;
The mapping has a real caveat. InnoDB typically owns most of the machine's memory, so a buffer pool miss usually means a disk read. PostgreSQL deliberately keeps shared_buffers smaller and leans on the operating system page cache, so a blks_read is often served from OS memory, not disk. A 95 percent cache hit ratio on PostgreSQL can perform beautifully; the same number on InnoDB might not. On PostgreSQL 16 and later, pg_stat_io gives a much more honest breakdown of read and write activity by backend type and context, and it belongs on the rebuilt dashboard next to the classic ratio.
The practical advice: track the trend, not the absolute number. A cache hit ratio that drops after a deploy means the working set changed; that was true on both systems.
History list length becomes dead tuples and transaction age
This is the most important translation in the table, because it is where the architectures genuinely diverge. InnoDB keeps old row versions in undo logs, and the purge threads clean them up. When a long-running transaction pins old versions, history list length grows, and every experienced MySQL operator knows the smell of a purge lag incident.
PostgreSQL stores old row versions in the table itself, and vacuum is the purge mechanism. The history list equivalent is therefore two metrics: dead tuples per table, and the age of the oldest thing holding back cleanup.
SELECT schemaname,
relname,
n_dead_tup,
n_live_tup,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 15;
Pair that with the oldest open transaction and the oldest backend xmin from pg_stat_activity, and with the database-level datfrozenxid age from pg_database if you want the wraparound early warning. The behavioral difference matters: on MySQL, purge lag degrades performance gradually and recovers quietly once the pinning transaction ends. On PostgreSQL, dead tuples occupy space in the table and its indexes until vacuum reclaims it, so a bad week leaves physical bloat behind. The monitoring posture has to be more proactive, which is covered in more depth in the general PostgreSQL monitoring guide.
The slow query log becomes pg_stat_statements
MySQL's slow query log plus pt-query-digest was the standard workflow: set long_query_time, harvest the log, aggregate by fingerprint. PostgreSQL has log_min_duration_statement, which behaves much like the slow log, but the center of gravity is the pg_stat_statements extension: an always-on, in-memory aggregation by normalized query, with calls, total and mean execution time, rows, and buffer counts per query family.
The workflow change is significant and mostly positive. Instead of sampling only queries slower than a threshold, you see the whole workload, including the fast query that runs two hundred thousand times a minute and quietly owns half of total execution time. The slow log approach systematically missed that class of problem. Keep log_min_duration_statement configured anyway, because the log captures the exact parameter values and timing of individual outliers, which the normalized statistics cannot, and add auto_explain when you need the plan that was actually used.
One habit to carry over unchanged: snapshot the statistics regularly and diff them. pg_stat_statements is cumulative since the last reset, and a regression only shows clearly when you compare the before and after windows around a deploy.
Seconds_Behind_Source becomes replay lag
MySQL replication lag lives in SHOW REPLICA STATUS as Seconds_Behind_Source, with all its well-known lies during relay log catch-up and idle periods. PostgreSQL physical replication reports lag from the primary in pg_stat_replication, and it is more granular: write_lag, flush_lag, and replay_lag tell you whether the delay is network transmission, disk flush on the standby, or replay of WAL that has already arrived. You can also compute byte lag with pg_wal_lsn_diff between the primary's current WAL position and each standby's replayed position.
On the standby itself, the closest equivalent to Seconds_Behind_Source is the difference between now and pg_last_xact_replay_timestamp, and it shares the same failure mode: on an idle primary it grows even though nothing is behind. Alert on byte lag and replay_lag together, and tune the threshold to the consumer. A reporting replica tolerating minutes and a read path behind a load balancer tolerating seconds should never share an alert policy.
Also watch replication slots. MySQL's binlog retention was a disk-space setting; a PostgreSQL slot retains WAL indefinitely for a disconnected consumer, which can fill the disk on the primary. There is no InnoDB equivalent, and it surprises every MySQL-trained operator once.
Rebuilding the dashboard, panel by panel
Here is the rebuild I recommend, mapped from a typical MySQL dashboard. Connections panel: total, active, and idle-in-transaction backends from pg_stat_activity, plus max_connections headroom. Throughput panel: xact_commit and xact_rollback rates from pg_stat_database in place of Questions and Com_ counters. Cache panel: cache hit ratio plus pg_stat_io reads and writes in place of buffer pool statistics. MVCC panel: dead tuples, oldest transaction age, and autovacuum activity in place of history list length. Query panel: top families by total time from pg_stat_statements in place of slow log digests. Replication panel: replay lag, byte lag, and slot retention in place of Seconds_Behind_Source. Locks panel: waiting sessions from pg_locks, which replaces the InnoDB lock wait sections of SHOW ENGINE INNODB STATUS.
Two things have no MySQL ancestor and still deserve panels: checkpoint activity, because a badly tuned max_wal_size causes periodic latency spikes that InnoDB's fuzzy checkpointing rarely produced at the same sharpness, and temporary file usage from pg_stat_database, which is the tell for work_mem spills. If you are also resizing hardware during the move, the PostgreSQL sizing guide covers how these signals feed capacity decisions.
How MonPG helps once you land on PostgreSQL
Everything above can be assembled by hand from catalog views, and I encourage every migrating team to learn the views regardless of tooling. But the value of the old MySQL dashboard was history: you knew what normal looked like. That is exactly what a fresh migration lacks, and it is the gap MonPG's PostgreSQL monitoring closes fastest. MonPG keeps pg_stat_statements history, session states, lock chains, vacuum and bloat signals, and replication lag in one place from the first day of the cutover, so the new baseline starts accumulating immediately.
MonPG monitors PostgreSQL only, so it will not watch the MySQL side during a dual-running migration period; keep your existing MySQL tooling for that. What it gives you is the other half: when someone asks whether the new PostgreSQL cluster is behaving differently this week than last week, you have the evidence instead of a shrug. Rebuilding intuition takes months. Rebuilding the dashboard should take an afternoon.