The Rise of Multi-Engine Database Estates
Ten years ago, most enterprise database teams were single-engine shops. You were a SQL Server house or an Oracle house, occasionally with a MySQL instance tucked away for a web application. That era is over. The 2025 Stack Overflow Developer Survey shows PostgreSQL as the most-used database among professional developers for the third consecutive year, while SQL Server remains deeply entrenched in enterprise ERP, financial, and healthcare systems.
The result is that a growing number of organizations now operate mixed fleets—SQL Server for legacy business-critical workloads, PostgreSQL for new cloud-native services, microservice backends, and data analytics platforms. This creates a practical challenge: the monitoring tools, diagnostic queries, and mental models for each engine are fundamentally different.
This article provides a side-by-side comparison of performance monitoring techniques for SQL Server and PostgreSQL, covering the core areas that DBAs and platform engineers need to master when managing both engines. We will examine the diagnostic views, the query analysis tools, the wait models, and the index management strategies that differ between the two platforms—and explore how a unified monitoring approach can bridge the gap.
System Catalog and Diagnostic Views: DMVs vs pg_stat
The foundation of performance monitoring in any relational database is the set of system views that expose runtime statistics. SQL Server and PostgreSQL take philosophically different approaches to this problem.
SQL Server: Dynamic Management Views (DMVs)
SQL Server provides over 200 Dynamic Management Views and Functions (DMVs/DMFs) organized into categories: execution, index, I/O, memory, transaction, and more. These views are queried like regular tables and return real-time or accumulated statistics since the last service restart.
-- Server-level: CPU, memory, connections
SELECT
cpu_count AS LogicalCPUs,
physical_memory_kb / 1024 AS PhysicalMemoryMB,
committed_kb / 1024 AS CommittedMemoryMB,
sqlserver_start_time AS LastRestart
FROM sys.dm_os_sys_info;
-- Active sessions with resource consumption
SELECT
r.session_id,
r.status,
r.cpu_time,
r.logical_reads,
r.wait_type,
r.wait_time,
t.text AS QueryText
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.status = 'running';
Key strengths of the DMV model:
- Extremely granular—you can correlate query execution stats with wait events at the session level
- No extensions required; all DMVs ship with every edition (including Express)
CROSS APPLYwith DMFs likesys.dm_exec_sql_text()andsys.dm_exec_query_plan()lets you join execution metadata with the actual SQL and XML plan
PostgreSQL: pg_stat Views and Extensions
PostgreSQL exposes runtime statistics through a family of pg_stat_* and pg_statio_* views. The core views ship with PostgreSQL, but some of the most valuable monitoring capabilities require extensions that must be explicitly enabled.
-- Server-level: connections and activity
SELECT
numbackends AS ActiveConnections,
xact_commit AS TotalCommits,
xact_rollback AS TotalRollbacks,
blks_read AS BlocksReadFromDisk,
blks_hit AS BlocksFromCache,
ROUND(100.0 * blks_hit /
NULLIF(blks_hit + blks_read, 0), 2) AS CacheHitPct
FROM pg_stat_database
WHERE datname = current_database();
-- Active queries with wait events
SELECT
pid,
state,
wait_event_type,
wait_event,
query_start,
LEFT(query, 120) AS query_preview
FROM pg_stat_activity
WHERE state != 'idle'
AND pid != pg_backend_pid()
ORDER BY query_start;
Critical extension: pg_stat_statements is not enabled by default. Without it, PostgreSQL provides no built-in equivalent to SQL Server's sys.dm_exec_query_stats. Add shared_preload_libraries = 'pg_stat_statements' to postgresql.conf and restart before you can track query-level resource consumption.
Comparison: Diagnostic View Coverage
| Monitoring Area | SQL Server | PostgreSQL |
|---|---|---|
| Query-level stats | sys.dm_exec_query_stats (built-in) |
pg_stat_statements (extension) |
| Active sessions | sys.dm_exec_requests + sys.dm_exec_sessions |
pg_stat_activity |
| Index usage | sys.dm_db_index_usage_stats |
pg_stat_user_indexes |
| Index physical stats | sys.dm_db_index_physical_stats() (function) |
pgstattuple (extension) |
| Table I/O | sys.dm_db_index_operational_stats() |
pg_statio_user_tables |
| Wait statistics | sys.dm_os_wait_stats (cumulative) |
pg_stat_activity.wait_event_type (point-in-time) |
| Missing indexes | sys.dm_db_missing_index_* (built-in) |
No built-in equivalent; use pg_qualstats or EXPLAIN analysis |
| Lock monitoring | sys.dm_tran_locks |
pg_locks + pg_stat_activity |
| Memory usage | sys.dm_os_buffer_descriptors, sys.dm_os_memory_clerks |
pg_buffercache (extension) |
| Query plan cache | sys.dm_exec_cached_plans |
No direct equivalent; plans are not globally cached |
Key takeaway: SQL Server ships with more built-in diagnostic surface area. PostgreSQL relies on extensions for several critical monitoring capabilities. A monitoring platform that targets both engines must account for these differences and handle the case where extensions are not yet enabled.
Wait Statistics: Two Different Models
Wait statistics tell you why queries are slow, not just that they are slow. Both engines expose wait information, but the models differ significantly in structure and how you consume the data.
SQL Server Wait Stats
SQL Server maintains a cumulative wait stats counter in sys.dm_os_wait_stats. Every time a worker thread waits for a resource (lock, I/O, network, memory, CPU scheduler), the engine increments the corresponding wait type's counter. There are over 900 documented wait types.
-- Top 10 wait types by total wait time (excluding idle/benign waits)
WITH WaitFiltered AS (
SELECT
wait_type,
wait_time_ms / 1000.0 AS wait_sec,
signal_wait_time_ms / 1000.0 AS signal_sec,
(wait_time_ms - signal_wait_time_ms) / 1000.0 AS resource_sec,
waiting_tasks_count
FROM sys.dm_os_wait_stats
WHERE wait_type NOT IN (
'CLR_SEMAPHORE','LAZYWRITER_SLEEP','RESOURCE_QUEUE',
'SQLTRACE_BUFFER_FLUSH','SLEEP_TASK','SLEEP_SYSTEMTASK',
'WAITFOR','BROKER_TASK_STOP','BROKER_TO_FLUSH',
'CHECKPOINT_QUEUE','XE_TIMER_EVENT','XE_DISPATCHER_WAIT',
'FT_IFTS_SCHEDULER_IDLE_WAIT','HADR_FILESTREAM_IOMGR_IOCOMPLETION'
)
AND wait_time_ms > 0
)
SELECT TOP 10
wait_type,
CAST(wait_sec AS DECIMAL(18,1)) AS TotalWaitSec,
CAST(resource_sec AS DECIMAL(18,1)) AS ResourceWaitSec,
CAST(signal_sec AS DECIMAL(18,1)) AS SignalWaitSec,
waiting_tasks_count AS WaitCount,
CAST(wait_sec * 100.0 / SUM(wait_sec) OVER()
AS DECIMAL(5,1)) AS PctOfTotal
FROM WaitFiltered
ORDER BY wait_sec DESC;
The cumulative model means you can snapshot the counters at time T1, again at T2, and compute the delta to understand what the server was waiting on during that window. This is the foundation of virtually every SQL Server performance analysis.
PostgreSQL Wait Events
PostgreSQL adopted a point-in-time sampling model for wait events. The pg_stat_activity view shows the current wait_event_type and wait_event for each active backend—but only the current state, not an accumulated history.
-- Snapshot current wait events across all backends
SELECT
wait_event_type,
wait_event,
COUNT(*) AS backends_waiting,
ROUND(100.0 * COUNT(*) /
NULLIF(SUM(COUNT(*)) OVER(), 0), 1) AS pct_of_waiting
FROM pg_stat_activity
WHERE state = 'active'
AND wait_event IS NOT NULL
AND pid != pg_backend_pid()
GROUP BY wait_event_type, wait_event
ORDER BY backends_waiting DESC;
-- Wait event categories in PostgreSQL:
-- LWLock - Lightweight locks (buffer mapping, WAL insert, etc.)
-- Lock - Heavyweight locks (relation, row, advisory)
-- BufferPin - Waiting for a buffer pin
-- Activity - Background process waits (autovacuum, WAL writer)
-- Client - Waiting for client (ClientRead, ClientWrite)
-- IPC - Inter-process communication
-- Timeout - Timer-based waits
-- IO - Disk I/O waits (DataFileRead, WALWrite, etc.)
| Aspect | SQL Server | PostgreSQL |
|---|---|---|
| Model | Cumulative counters (since restart) | Point-in-time sampling per backend |
| Granularity | 900+ wait types with millisecond precision | ~120 wait events across 8 categories |
| Historical analysis | Delta between two snapshots | Requires external sampling and aggregation |
| Query-level waits | Query Store captures per-query wait stats (2017+) | Not natively linked to specific queries |
| Common high-impact waits | CXPACKET, PAGEIOLATCH_SH, LCK_M_*, SOS_SCHEDULER_YIELD |
LWLock:BufferMapping, IO:DataFileRead, Lock:relation, Client:ClientRead |
Practical implication: To build a wait-stats profile for PostgreSQL comparable to what SQL Server offers natively, you need to run periodic samples of pg_stat_activity and aggregate them yourself—or use an extension like pg_wait_sampling. A unified monitoring platform must implement this sampling automatically.
Index Management: B-Tree Everywhere, Details Diverge
Both engines use B-tree as the default index structure, but index maintenance, bloat detection, and unused-index identification work differently.
Fragmentation and Bloat
In SQL Server, B-tree fragmentation is measured by sys.dm_db_index_physical_stats(), which reports the percentage of out-of-order pages (logical fragmentation) and the percentage of unused page space (average page density). DBAs defragment with ALTER INDEX REORGANIZE (online, low-impact) or ALTER INDEX REBUILD (more thorough, optionally online in Enterprise edition).
-- Index fragmentation report for tables with >1000 pages
SELECT
OBJECT_SCHEMA_NAME(ips.object_id) + '.' +
OBJECT_NAME(ips.object_id) AS TableName,
i.name AS IndexName,
ips.index_type_desc,
ips.avg_fragmentation_in_percent AS FragPct,
ips.page_count,
ips.avg_page_space_used_in_percent AS DensityPct
FROM sys.dm_db_index_physical_stats(
DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
JOIN sys.indexes i
ON ips.object_id = i.object_id
AND ips.index_id = i.index_id
WHERE ips.page_count > 1000
AND ips.avg_fragmentation_in_percent > 10
ORDER BY ips.avg_fragmentation_in_percent DESC;
PostgreSQL does not have a direct equivalent of fragmentation percentage. Instead, the concern is bloat—dead tuples that accumulate between vacuum cycles. The pgstattuple extension provides a pgstattuple() function that reports dead tuple percentage, but it requires a full table scan and should be used cautiously on large tables.
-- Table bloat estimation using pg_stat_user_tables
SELECT
schemaname || '.' || relname AS table_name,
n_live_tup AS live_tuples,
n_dead_tup AS dead_tuples,
CASE WHEN n_live_tup > 0
THEN ROUND(100.0 * n_dead_tup /
(n_live_tup + n_dead_tup), 1)
ELSE 0 END AS dead_pct,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
-- Detailed bloat analysis (requires pgstattuple extension)
-- CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('public.orders');
Unused Index Detection
Both engines track index usage, but through different views. The goal is the same: find indexes that consume disk space and slow down writes but are never used for reads.
| Operation | SQL Server | PostgreSQL |
|---|---|---|
| Index usage tracking | sys.dm_db_index_usage_stats — tracks seeks, scans, lookups, and updates since last restart |
pg_stat_user_indexes — tracks idx_scan, idx_tup_read, idx_tup_fetch since last stats reset |
| Identifying unused indexes | user_seeks = 0 AND user_scans = 0 AND user_lookups = 0 |
idx_scan = 0 |
| Index size | sys.dm_db_index_physical_stats page_count * 8 KB |
pg_relation_size(indexrelid) |
| Missing index suggestions | sys.dm_db_missing_index_details / _group_stats (built-in) |
No built-in; use pg_qualstats extension or auto_explain + log analysis |
| Stats reset | Automatic on service restart only | pg_stat_reset() manually or on restart |
Caveat: Both engines reset usage stats on restart. An index that shows zero usage may simply not have been needed since the last restart—but could be critical for a monthly reporting query. Always verify against workload patterns before dropping.
Query Plan Analysis
Understanding execution plans is essential for query tuning on both platforms. The plan formats and the way you access them differ considerably.
SQL Server: XML Plans, Query Store, and Plan Forcing
SQL Server represents execution plans as XML documents. You can retrieve them via SET STATISTICS XML ON, through DMVs (sys.dm_exec_query_plan()), or from the Query Store.
-- Retrieve cached plan for a known query hash
SELECT
qs.query_hash,
qs.execution_count,
qs.total_worker_time / qs.execution_count AS avg_cpu_us,
qs.total_logical_reads / qs.execution_count AS avg_reads,
TRY_CONVERT(XML, qp.query_plan) AS QueryPlan
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE qs.query_hash = 0xABCDEF0123456789
ORDER BY qs.total_worker_time DESC;
-- Query Store: detect plan regressions (SQL Server 2016+)
SELECT
q.query_id,
p.plan_id,
rs.avg_duration,
rs.avg_cpu_time,
rs.avg_logical_io_reads,
p.is_forced_plan
FROM sys.query_store_query q
JOIN sys.query_store_plan p ON q.query_id = p.query_id
JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id
WHERE rs.avg_duration > 1000000 -- > 1 second average
ORDER BY rs.avg_duration DESC;
Query Store (available since SQL Server 2016) is a game-changer: it persists execution plans and runtime statistics across restarts, enables plan forcing, and provides built-in regression detection.
PostgreSQL: EXPLAIN, auto_explain, and pg_stat_statements
PostgreSQL uses the EXPLAIN command to display execution plans. Plans are returned as text, JSON, XML, or YAML. Unlike SQL Server, PostgreSQL does not maintain a global plan cache that you can query—each backend compiles plans independently.
-- Detailed execution plan with actual runtime statistics
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.order_id, c.name, o.total
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.order_date >= '2026-01-01'
AND o.status = 'shipped';
/* Example output:
Hash Join (cost=1.52..234.67 rows=1842 width=52)
(actual time=0.089..3.421 rows=1837 loops=1)
Hash Cond: (o.customer_id = c.id)
Buffers: shared hit=312
-> Seq Scan on orders o (cost=0.00..198.50 rows=1842 width=24)
(actual time=0.021..1.832 rows=1837 loops=1)
Filter: (order_date >= '2026-01-01' AND status = 'shipped')
Rows Removed by Filter: 8163
Buffers: shared hit=285
-> Hash (cost=1.15..1.15 rows=30 width=36)
(actual time=0.042..0.043 rows=30 loops=1)
Buffers: shared hit=1
-> Seq Scan on customers c ...
*/
-- Enable auto_explain to log slow query plans automatically
-- In postgresql.conf:
-- shared_preload_libraries = 'auto_explain'
-- auto_explain.log_min_duration = '500ms'
-- auto_explain.log_analyze = true
-- auto_explain.log_buffers = true
| Capability | SQL Server | PostgreSQL |
|---|---|---|
| Plan format | XML (also graphical in SSMS) | Text, JSON, XML, YAML |
| Plan persistence | Query Store (across restarts) | Not persisted; use auto_explain to log |
| Plan forcing | sp_query_store_force_plan |
No native equivalent; use pg_hint_plan extension |
| Regression detection | Built into Query Store UI + DMVs | Manual analysis via pg_stat_statements deltas |
| Parameterization | Auto-parameterization + forced parameterization | Prepared statements; planner handles generic vs. custom plans |
| Plan cache visibility | sys.dm_exec_cached_plans + sys.dm_exec_plan_attributes |
No global cache; backends compile independently |
Memory and I/O Monitoring
Both engines use buffer pools to cache data pages in memory, reducing disk I/O. But the internals and the diagnostic surfaces are structured differently.
SQL Server Buffer Pool
SQL Server's buffer pool is managed by a single memory manager. You can inspect it through sys.dm_os_buffer_descriptors (which pages are cached), sys.dm_os_memory_clerks (memory allocation by component), and sys.dm_os_performance_counters (page life expectancy, buffer cache hit ratio).
-- Buffer cache hit ratio and page life expectancy
SELECT
object_name,
counter_name,
cntr_value
FROM sys.dm_os_performance_counters
WHERE counter_name IN (
'Buffer cache hit ratio',
'Buffer cache hit ratio base',
'Page life expectancy',
'Page reads/sec',
'Page writes/sec'
)
AND object_name LIKE '%Buffer Manager%';
-- Memory consumption by database in buffer pool
SELECT
DB_NAME(database_id) AS DatabaseName,
COUNT(*) * 8 / 1024 AS CachedMB
FROM sys.dm_os_buffer_descriptors
WHERE database_id > 4 -- Exclude system databases
GROUP BY database_id
ORDER BY CachedMB DESC;
PostgreSQL Shared Buffers
PostgreSQL's equivalent is the shared_buffers area, typically configured to 25% of system RAM. The pg_buffercache extension exposes which pages are currently cached, while pg_stat_bgwriter tracks background writer and checkpoint I/O activity.
-- Cache hit ratio per table (from pg_statio_user_tables)
SELECT
schemaname || '.' || relname AS table_name,
heap_blks_hit AS cache_hits,
heap_blks_read AS disk_reads,
CASE WHEN heap_blks_hit + heap_blks_read > 0
THEN ROUND(100.0 * heap_blks_hit /
(heap_blks_hit + heap_blks_read), 2)
ELSE 100 END AS hit_ratio_pct
FROM pg_statio_user_tables
WHERE heap_blks_hit + heap_blks_read > 1000
ORDER BY disk_reads DESC;
-- Checkpoint and background writer activity
SELECT
checkpoints_timed,
checkpoints_req AS checkpoints_forced,
buffers_checkpoint,
buffers_clean AS bgwriter_buffers,
buffers_backend AS backend_direct_writes,
ROUND(100.0 * buffers_backend /
NULLIF(buffers_checkpoint + buffers_clean
+ buffers_backend, 0), 1) AS backend_write_pct
FROM pg_stat_bgwriter;
Performance indicator: In PostgreSQL, a high backend_write_pct (backends performing direct writes instead of the background writer) indicates that shared_buffers is too small or checkpoint frequency is too low. The equivalent SQL Server signal is low Page Life Expectancy combined with high PAGEIOLATCH_SH waits.
The Tools Landscape
The commercial monitoring ecosystem reflects the two-engine split. Most tools specialize in one engine or the other, and the few that claim multi-engine support often treat the secondary engine as an afterthought.
| Tool | SQL Server | PostgreSQL | Notes |
|---|---|---|---|
| SQL Server Management Studio | Full | None | Microsoft first-party; Activity Monitor, Query Store UI |
| pgAdmin / DBeaver | Basic / Full | Full | pgAdmin is PostgreSQL-only; DBeaver is multi-engine but limited monitoring |
| Redgate SQL Monitor | Deep | None | Agent-based, excellent SQL Server diagnostics |
| pganalyze | None | Deep | SaaS, PostgreSQL-only, strong query analysis |
| SolarWinds DPA | Good | Good | Multi-engine but agent-based; wait analysis focused |
| Datadog / New Relic | Good | Good | APM-oriented; database is one layer among many |
| DPO | Deep | Deep | 100% agentless; unified DPO Score across engines; read-only SQL scripts |
The Unified Monitoring Challenge
Running separate monitoring stacks for SQL Server and PostgreSQL creates several practical problems:
- Context switching — DBAs must learn and maintain expertise in two completely different diagnostic interfaces. The mental model for "why is this query slow" is different in each engine.
- Inconsistent alerting — Threshold definitions, severity levels, and notification channels diverge. A "critical" alert from the PostgreSQL tool may not carry the same weight as one from the SQL Server tool.
- No cross-engine fleet view — When the CTO asks "how healthy is our database layer?", nobody can answer without manually correlating data from two dashboards.
- Double the agent overhead — Most monitoring tools install agents on the target server. Two engines means two agents, two maintenance windows for agent updates, and two security reviews.
What a Unified Approach Requires
A platform that genuinely unifies SQL Server and PostgreSQL monitoring needs to solve four problems:
- Engine-specific collection — Use DMVs for SQL Server and
pg_statviews (plus extensions) for PostgreSQL. Do not try to force one engine's idioms onto the other. - Common finding taxonomy — Map engine-specific signals to a shared vocabulary. "High parallelism contention" is one finding whether it comes from
CXPACKETwaits or PostgreSQL parallel worker contention. - Normalized scoring — A health score that means the same thing regardless of engine. A SQL Server instance scoring 72 and a PostgreSQL cluster scoring 72 should represent comparable health states.
- Zero footprint collection — In security-sensitive environments, installing agents is often a non-starter. Read-only scripts executed over a standard database connection eliminate that barrier entirely.
How DPO Handles Multi-Engine Collection
The DPO Collector uses two provider implementations—SqlServerProvider and PostgreSqlProvider—that share the same interface but issue engine-native queries. The SqlServerProvider queries DMVs and sys.* catalog views; the PostgreSqlProvider queries pg_stat_* views and safely checks for extensions like pg_stat_statements before attempting to use them. Both providers emit findings in a common taxonomy that feeds into the same DPO Score calculation.
Practical Examples: Same Question, Different Queries
To illustrate just how different the diagnostic experience is across engines, here are three common monitoring questions and the queries you would run on each platform.
Question 1: "What are the top 5 most expensive queries right now?"
SELECT TOP 5
qs.total_worker_time / qs.execution_count AS avg_cpu_us,
qs.execution_count,
qs.total_logical_reads,
SUBSTRING(st.text,
(qs.statement_start_offset / 2) + 1,
(CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset) / 2 + 1) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY qs.total_worker_time DESC;
SELECT
queryid,
LEFT(query, 200) AS query_text,
calls AS execution_count,
total_exec_time / calls AS avg_ms,
shared_blks_hit + shared_blks_read AS total_blocks
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
Question 2: "Which tables have the worst cache hit ratio?"
SELECT TOP 10
OBJECT_SCHEMA_NAME(ios.object_id) + '.' +
OBJECT_NAME(ios.object_id) AS TableName,
SUM(ios.page_io_latch_wait_count) AS PhysicalReads,
SUM(ios.page_io_latch_wait_in_ms) AS IOWaitMs
FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) ios
GROUP BY ios.object_id
ORDER BY PhysicalReads DESC;
SELECT
schemaname || '.' || relname AS table_name,
heap_blks_read AS disk_reads,
heap_blks_hit AS cache_hits,
ROUND(100.0 * heap_blks_hit /
NULLIF(heap_blks_hit + heap_blks_read, 0), 1) AS hit_pct
FROM pg_statio_user_tables
WHERE heap_blks_read > 100
ORDER BY heap_blks_read DESC
LIMIT 10;
Question 3: "Are there long-running transactions holding locks?"
SELECT
s.session_id,
s.login_name,
s.host_name,
t.text AS QueryText,
DATEDIFF(SECOND,
r.start_time, GETDATE()) AS DurationSec,
r.blocking_session_id,
r.wait_type
FROM sys.dm_exec_sessions s
JOIN sys.dm_exec_requests r ON s.session_id = r.session_id
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE DATEDIFF(SECOND, r.start_time, GETDATE()) > 30
ORDER BY DurationSec DESC;
SELECT
pid,
usename,
client_addr,
LEFT(query, 200) AS query_text,
EXTRACT(EPOCH FROM
(NOW() - xact_start)) AS xact_duration_sec,
wait_event_type,
wait_event,
state
FROM pg_stat_activity
WHERE state != 'idle'
AND xact_start < NOW() - INTERVAL '30 seconds'
ORDER BY xact_start;
-- Check for lock blockers
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocker.pid AS blocker_pid,
blocker.query AS blocker_query
FROM pg_locks bl
JOIN pg_stat_activity blocked ON bl.pid = blocked.pid
JOIN pg_locks kl
ON bl.locktype = kl.locktype
AND bl.database IS NOT DISTINCT FROM kl.database
AND bl.relation IS NOT DISTINCT FROM kl.relation
AND bl.page IS NOT DISTINCT FROM kl.page
AND bl.tuple IS NOT DISTINCT FROM kl.tuple
AND bl.transactionid IS NOT DISTINCT FROM kl.transactionid
AND bl.pid != kl.pid
AND NOT bl.granted
AND kl.granted
JOIN pg_stat_activity blocker ON kl.pid = blocker.pid;
Configuration Monitoring
Server configuration affects every dimension of performance. Both engines provide views into their configuration, but the structure differs.
| Aspect | SQL Server | PostgreSQL |
|---|---|---|
| Configuration view | sys.configurations (77 settings) |
pg_settings (~350 settings) |
| Pending changes | value vs. value_in_use columns |
pending_restart column |
| Change requires restart | Some settings (e.g., max server memory) are dynamic; others need restart |
context column: postmaster = restart, sighup = reload, user = session |
| File-level config | Registry + sp_configure |
postgresql.conf + pg_hba.conf + pg_file_settings view |
| Critical settings | max server memory, cost threshold for parallelism, max degree of parallelism |
shared_buffers, work_mem, effective_cache_size, max_connections |
Configuration drift: In multi-server environments, configuration settings should be consistent across servers filling the same role. DPO's drift detection engine compares sys.configurations values (SQL Server) or pg_settings values (PostgreSQL) across server pairs and flags divergences as findings.
Building Your Cross-Engine Monitoring Strategy
If you are managing both SQL Server and PostgreSQL in production today, here is a practical framework for building a unified monitoring strategy:
- Standardize on extensions first. Enable
pg_stat_statements,auto_explain, andpg_buffercacheon all PostgreSQL instances. Without them, your PostgreSQL monitoring will always be weaker than SQL Server. - Define a common metric vocabulary. Map equivalent concepts: Page Life Expectancy (SQL Server) maps to cache hit ratio (PostgreSQL).
CXPACKETwaits map to parallel worker contention. Create a shared glossary your team uses regardless of engine. - Adopt a single scoring framework. Whether you use the DPO Score or build your own, the key is that one number means the same thing across engines. This enables fleet-wide triage.
- Automate collection on a schedule. Manual health checks do not scale. Schedule read-only collection scripts to run daily or hourly, store results centrally, and surface trends automatically.
- Centralize alerting. Route findings from both engines into the same notification channel with consistent severity levels. A "Critical" finding on PostgreSQL should trigger the same response as a "Critical" on SQL Server.
Conclusion
SQL Server and PostgreSQL are both excellent database engines, but they expose their internals in fundamentally different ways. SQL Server offers a richer built-in diagnostic surface with DMVs, Query Store, and cumulative wait statistics. PostgreSQL offers flexibility and extensibility but requires intentional setup of extensions to achieve comparable monitoring depth.
For organizations running both engines—which is an increasingly common reality—the challenge is not choosing between them but monitoring them consistently. That means engine-specific collection, a common finding taxonomy, normalized scoring, and a single dashboard where a DBA can see the health of every server regardless of its engine.
The goal is not to make PostgreSQL look like SQL Server or vice versa. It is to give your team one language for database health so they can focus on fixing problems instead of translating between diagnostic interfaces.
Unify Your Database Monitoring
DPO provides deep, agentless monitoring for both SQL Server and PostgreSQL with a single DPO Score across your entire fleet. No agents, no installation, no access to your business data.
Request a Demo