Multi-Engine

PostgreSQL vs SQL Server: Performance Monitoring Compared

April 1, 2026 15 min read Multi-Engine

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.

SQL Server
-- 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:

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.

PostgreSQL
-- 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.

SQL Server
-- 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.

PostgreSQL
-- 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).

SQL Server
-- 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.

PostgreSQL
-- 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.

SQL Server
-- 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.

PostgreSQL
-- 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).

SQL Server
-- 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.

PostgreSQL
-- 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:

What a Unified Approach Requires

A platform that genuinely unifies SQL Server and PostgreSQL monitoring needs to solve four problems:

  1. Engine-specific collection — Use DMVs for SQL Server and pg_stat views (plus extensions) for PostgreSQL. Do not try to force one engine's idioms onto the other.
  2. Common finding taxonomy — Map engine-specific signals to a shared vocabulary. "High parallelism contention" is one finding whether it comes from CXPACKET waits or PostgreSQL parallel worker contention.
  3. 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.
  4. 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?"

SQL Server
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;
PostgreSQL
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?"

SQL Server
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;
PostgreSQL
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?"

SQL Server
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;
PostgreSQL
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:

  1. Standardize on extensions first. Enable pg_stat_statements, auto_explain, and pg_buffercache on all PostgreSQL instances. Without them, your PostgreSQL monitoring will always be weaker than SQL Server.
  2. Define a common metric vocabulary. Map equivalent concepts: Page Life Expectancy (SQL Server) maps to cache hit ratio (PostgreSQL). CXPACKET waits map to parallel worker contention. Create a shared glossary your team uses regardless of engine.
  3. 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.
  4. 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.
  5. 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