
Data
Postgres vs SQL Server: Standard, Enterprise, or Neither
TDE, columnstore and In-Memory OLTP ship in SQL Server Standard. Readable secondaries and multi-database availability groups do not. That line decides it.
Treated as a feature comparison, this question gives a stale answer in 2026. The capabilities teams budget an Enterprise upgrade for mostly stopped being Enterprise-only years ago. What did not change is the line that forces the upgrade — and it is a topology line, not a feature one.
The framing is backwards too. The useful question has three answers, not two: does this workload need Enterprise, does it fit Standard, or is the only thing Enterprise would buy something Postgres gives away?
The arithmetic that starts the conversation
Microsoft publishes the numbers, so no reseller restatement is needed. On the Per Core model the SQL Server 2025 price sheet lists Enterprise at $15,123 per two-core pack and Standard at $3,945, with subscription equivalents of $5,434 and $1,418 per year. The sheet is explicit: "Pricing represents open no-level (NL) estimated retail price. For your specific pricing, contact your Microsoft reseller." What follows is a list-price model, not a quote.
Two counting rules turn a price into a bill. Core SKUs "are sold in packs of two-core licenses each", and a physical server needs "sufficient licenses based on the number of physical cores on the server, with a minimum of four Licenses per Physical Processor". A two-socket, sixteen-core server takes sixteen core licences — eight packs: $31,560 on Standard, $120,984 on Enterprise.
Which core number goes in is where models go wrong. Per-VM licensing is a different rule with a different input: virtual cores, plus — "if any virtual core is mapped to multiple hardware threads" — every thread each maps to; a four-licence minimum per virtual OSE; and eligibility only with Software Assurance or a subscription. It is under that per-VM heading that Microsoft notes "the 32-core limit for the Standard edition means 32 hardware threads". Pass a logical-processor count while licensing a physical server and you roughly double the bill on a hyperthreaded host.
-- Inventory for a SQL Server licensing decision.
-- Prints the inputs the two published licensing models actually consume.
-- (a) Edition, build and HADR state of the instance being priced.
SELECT
SERVERPROPERTY('Edition') AS edition,
SERVERPROPERTY('ProductVersion') AS product_version,
SERVERPROPERTY('ProductLevel') AS product_level,
SERVERPROPERTY('IsHadrEnabled') AS hadr_enabled;
-- (b) Sockets and cores AS SEEN BY THE SYSTEM THIS INSTANCE RUNS ON.
-- REQUIRES SQL SERVER 2016 SP2 OR LATER: socket_count, cores_per_socket
-- and numa_node_count are all documented "Applies to: SQL Server 2016
-- (13.x) SP2 and later versions". On 2016 RTM/SP1 or earlier this part
-- fails with Invalid column name 'socket_count'. The fallback is
-- cpu_count with hyperthread_ratio from the same DMV -- "the ratio of the
-- number of logical or physical cores that are exposed by one physical
-- processor package" -- or a hardware/hypervisor inventory. Neither is a
-- substitute for a physical-core count; both are inputs to estimating it.
-- sys.dm_os_sys_info documents socket_count as "the number of processor
-- sockets available on the system" -- the system being the OS the instance
-- runs in. On bare metal that product IS the figure the physical-server
-- rule licenses, and the figure the four-licences-per-physical-processor
-- floor applies to. Inside a guest VM it is the GUEST's virtual topology:
-- the host's physical sockets and cores cannot be read from this DMV at
-- all and must come from the hypervisor or a hardware inventory. The
-- documentation says only "the system"; the guest reading is an inference
-- from that word, and it is the caveat that most often breaks a model.
-- Do NOT substitute the logical count below for either.
SELECT
socket_count AS sockets_visible_to_this_instance,
cores_per_socket,
socket_count * cores_per_socket AS cores_visible_to_this_instance,
-- (c) LOGICAL processors / schedulers. Relevant only under per-VM licensing,
-- where every hardware thread a virtual core maps to must also be
-- licensed -- and where Standard's 32-core limit means 32 hardware
-- threads. Per-VM licensing itself requires SA or a subscription.
cpu_count AS logical_processors_per_vm_model,
scheduler_count
FROM sys.dm_os_sys_info;
-- (d) Configured memory against Standard's 256 GB buffer-pool ceiling. The
-- documented limit is "Maximum memory for buffer pool per instance", and
-- max server memory bounds more than the buffer pool -- so this is the
-- knob a DBA checks, not literally the same object as the limit.
SELECT
name,
value_in_use AS configured_mb,
256 * 1024 AS standard_buffer_pool_ceiling_mb
FROM sys.configurations
WHERE name IN ('max server memory (MB)', 'min server memory (MB)');
-- (e) Memory-optimized data against Standard's 32 GB per-database cap.
-- UNLIKE (a)-(d), THIS PART IS NOT INSTANCE-WIDE.
-- sys.dm_db_xtp_memory_consumers reports only the database you are
-- connected to, and the cap is per DATABASE -- so run part (e) in every
-- user database, not once in master. A zero from master says nothing
-- about a user database on the same instance that is over the cap.
-- A database with no memory-optimized objects returns no rows here, so
-- the bare SUM would be NULL, not 0 -- ISNULL makes that read as zero
-- usage rather than as a broken query.
SELECT
DB_NAME() AS database_name,
ISNULL(SUM(allocated_bytes) / 1024 / 1024, 0) AS in_memory_oltp_allocated_mb,
32 * 1024 AS standard_per_database_cap_mb
FROM sys.dm_db_xtp_memory_consumers;
-- Sweep every database instead of trusting one connection. sp_MSforeachdb is
-- undocumented; a cursor over sys.databases where state = 0 is the supported
-- equivalent if you would rather not depend on it.
-- EXEC sp_MSforeachdb N'USE [?];
-- SELECT DB_NAME() AS database_name,
-- ISNULL(SUM(allocated_bytes) / 1024 / 1024, 0) AS in_memory_oltp_allocated_mb
-- FROM sys.dm_db_xtp_memory_consumers;';The second published path exists on only one side of that line. Standard has a Server + CAL model at $989 per server plus $230 per CAL; the editions table's scale-limits footnote records that Enterprise "with Server + Client Access License (CAL) based licensing (not available for new agreements) is limited to a maximum of 20 cores per SQL Server instance". CALs "are required even when the server is accessed indirectly", and "using hardware or software that reduces the number of devices or users that directly access or use the software (multiplexing/pooling) does not reduce the number of CALs required". A connection pool does not collapse ten thousand users into four application servers.
#!/usr/bin/env bash
# SQL Server 2025 list-price model. NOT A QUOTE.
#
# The Per Core and server prices here are Microsoft's published open no-level
# estimated retail prices from the SQL Server 2025 pricing sheet, whose
# footnote on those rows reads: "Pricing represents open no-level (NL)
# estimated retail price. For your specific pricing, contact your Microsoft
# reseller." The two subscription figures come from that sheet's separate
# subscription and add-on pricing table. Negotiated EA/MPSA pricing is
# normally lower. This models the SHAPE of the decision, not your invoice.
set -euo pipefail
# --- Inputs -----------------------------------------------------------------
# CORES: which number belongs here depends on which model you are pricing.
# Licensing a PHYSICAL server -> pass PHYSICAL cores. The four-licence floor
# below applies to this figure.
# Licensing a VIRTUAL MACHINE -> pass virtual cores, PLUS every hardware
# thread each virtual core is mapped to. Per-VM licensing requires
# Software Assurance or a subscription and has its own four-licence
# minimum per virtual OSE. The two models are not interchangeable.
CORES=${CORES:-16}
SOCKETS=${SOCKETS:-2}
# USERS_DEVICES: EVERY user and device that accesses the instance, including
# indirect access through an application tier, and including devices nobody
# attends. Multiplexing or pooling does not reduce the count. This is not your
# visible seat count, and the break-even below is only valid against the
# honest total.
USERS_DEVICES=${USERS_DEVICES:-200}
# --- Published open no-level prices -----------------------------------------
ENT_PACK=15123 # Enterprise, 2-core pack, perpetual
STD_PACK=3945 # Standard - per core, 2-core pack, perpetual
# Subscription is a separate licensing model, not a per-year Per Core rate.
ENT_PACK_YR=5434 # Enterprise, 2-core pack, subscription
STD_PACK_YR=1418 # Standard, 2-core pack, subscription
STD_SERVER=989 # Standard - server (Server + CAL model)
STD_CAL=230 # Standard - CAL
# --- Core licence count -----------------------------------------------------
# Minimum four licences per physical processor, then rounded up to whole
# two-core packs.
floor=$(( SOCKETS * 4 ))
licences=$(( CORES > floor ? CORES : floor ))
packs=$(( (licences + 1) / 2 ))
std_percore=$(( packs * STD_PACK ))
ent_percore=$(( packs * ENT_PACK ))
std_year=$(( packs * STD_PACK_YR ))
ent_year=$(( packs * ENT_PACK_YR ))
std_servercal=$(( STD_SERVER + USERS_DEVICES * STD_CAL ))
breakeven=$(( (std_percore - STD_SERVER) / STD_CAL ))
printf '%d cores / %d sockets -> %d core licences -> %d two-core packs\n\n' \
"$CORES" "$SOCKETS" "$licences" "$packs"
printf ' Standard Per Core, perpetual : $%d\n' "$std_percore"
printf ' Enterprise Per Core, perpetual : $%d\n' "$ent_percore"
printf ' Standard Subscription, 1 year: $%d\n' "$std_year"
printf ' Enterprise Subscription, 1 year: $%d\n' "$ent_year"
printf ' Standard Server + CAL (%d) : $%d\n' "$USERS_DEVICES" "$std_servercal"
printf '\n Server + CAL is cheaper ONLY at or below %d users AND devices,\n' "$breakeven"
printf ' counted with indirect access and unattended devices included.\n'
printf ' Enterprise Server + CAL is not available for new agreements.\n'
# --- The high-availability line, never folded into the single-node total ----
# Microsoft: "licenses without SA or non-subscription licenses do not include
# disaster recovery or high availability rights, including use of fail-over
# clusters", and passive fail-over replicas "may be implemented in a variety
# of ways, including via availability groups and fail-over cluster instances."
# The SA benefit is capped: one passive fail-over replica for high
# availability per licensed OSE, and its licence count "may not exceed the
# number of licenses needed to cover your primary" OSE.
# READ THE SECOND LINE BELOW AS A MODEL, NOT A PRICE: Microsoft states that
# those rights are not INCLUDED; it nowhere instructs you to license the
# passive node in full. Two fully licensed OSEs simply need no passive
# fail-over benefit, which is the standard reading -- but it is a reading.
printf '\nTwo-node Standard failover cluster instance:\n'
printf ' with SA or a subscription : $%d (ONE passive HA replica,\n' "$std_percore"
printf ' no larger than the primary)\n'
printf ' without : $%d (passive node licensed in full)\n' "$(( std_percore * 2 ))"
printf '\n NOTE: the SA or subscription cost that unlocks the first line is\n'
printf ' NOT in that figure. Microsoft publishes no SA price on the 2025\n'
printf ' sheet; the published alternative is the per-year subscription\n'
printf ' above. Passive-node coverage is conditional, not free.\n'At the sixteen-core shape above, Server + CAL stops being cheaper near 132 users and devices, counted honestly — a low ceiling, and an easy one to overshoot.
The edition line that actually decides it
Start with the correction, because it changes the budget. In the SQL Server 2025 editions table, transparent data encryption is Yes for Enterprise and Yes for Standard. So are Resource Governor and SQL Server Agent. Columnstore, In-Memory OLTP and Query Store are Yes in all three editions, the first two footnoted as "limited to the amount of memory specified by edition in the Scale Limits section" — capped, not withheld. None of these is a reason to buy Enterprise.
The caps still matter. Standard is limited to "lesser of 4 sockets or 32 cores" per instance — 32, raised from the 24 of SQL Server 2022 and earlier — plus 256 GB of buffer pool and 32 GB of memory-optimized data per database. One database needing 80 GB of memory-optimized data is an Enterprise workload even though In-Memory OLTP ships in Standard.
Now the line that decides it. The editions table reads counter-intuitively, and it is not a transcription error: Always On availability groups are Enterprise Yes, Standard No; Basic availability groups are Enterprise No, Standard Yes. A basic availability group "supports two replicas, with one database" — forty databases on one instance means forty availability groups and forty failover units. That secondary is inert too: "No read access on secondary replica. No backups on secondary replica. No integrity checks on secondary replicas." The nightly CHECKDB is not offloadable at Standard's price either.
Readable secondary replicas are only supported with SQL Server Enterprise edition.
That is the SQL Server on Linux availability-group overview, scoped to that platform. The same page is blunter about Standard: "one primary, and one secondary that can only be used for availability purposes. It can't be used for anything else, such as readable queries."
A SQL Server DBA answers all of that in one sentence: "or run a failover cluster instance." They are right. Standard supports Always On failover cluster instances on two nodes, against Enterprise's sixteen, and an FCI "is redundant at the server-instance level": every database on the instance fails over as one unit, no Enterprise licence. The multiplication above belongs to basic availability groups, not to Standard edition.
The decision test: which of the three your workload selects
A test over workload properties, not a ranking. Run it and you land on one branch.
- Stay on Standard with basic availability groups when the instance fits inside 32 cores (physical cores if you license the server, 32 hardware threads per VM), 256 GB of buffer pool and 32 GB of memory-optimized data per database; when few enough databases make one availability group each tolerable; and when nothing reads from the secondary.
- Stay on Standard with a two-node failover cluster instance when the workload needs one failover unit for the whole instance, shared storage is acceptable as the single copy of the data, nothing reads off a secondary, and you hold Software Assurance or a subscription — or have budgeted the second set of Standard core licences the passive node otherwise needs. Standard caps the FCI at two nodes, so patching the passive node is this branch's only rolling headroom.
- Buy Enterprise when read-scale off a secondary is real, when you need one failover unit across many databases *and* a second independent copy of the data, or when the core and memory ceilings bind. That is what the extra $89,424 per sixteen-core server buys at list price.
- Move to Postgres when those Enterprise-only lines are the only reason for the upgrade and nothing depends on Integration Services, Analysis Services, distributed transactions under an availability group on Linux, or a T-SQL procedural surface deep enough to be its own rewrite.
One correction to branches one and two: on Linux each basic availability group needs an arbitrating third replica, and that replica carries no licence. ALTER AVAILABILITY GROUP's CONFIGURATION_ONLY replica "can be hosted on any edition of SQL Server, including Express Edition". What multiplies per database is the group, its Pacemaker resource set and its listener, not the server count: Microsoft's basic availability groups page states that "You might have multiple Basic availability groups connected to a single instance of SQL Server", and nothing requires a separate arbitrating instance per group. The multiplication costs operational surface, not licence spend. Same discipline as pricing a cloud exit as a number.
The Postgres answers, feature by feature
If branch four is where you landed, the mapping matters more than the comparison — and the counterparts do not all have equal standing.
- Audit logging → pgaudit, which "provides detailed session and/or object audit logging via the standard PostgreSQL logging facility" and implements object-level auditing "via the roles system": the grants on the role named in
pgaudit.roleare your evidence scope. - SQL Server Agent → pg_cron, "a simple cron-based job scheduler for PostgreSQL ... that runs inside the database as an extension" — with one constraint: it "may only be installed to one database in a cluster", and jobs elsewhere go through
cron.schedule_in_database(). - The 2025 vector data type → pgvector. SQL Server's native type stores vectors "in an optimized binary format" but exposes them "as JSON arrays", to 1,998 dimensions at float32 and 3,996 at float16. pgvector covers "exact and approximate nearest neighbor search" over "single-precision, half-precision, binary, and sparse vectors".
- Transparent data encryption → pg_tde, Percona's "open source, community driven and futureproof PostgreSQL extension". This one has an asterisk; see below.
- Availability groups → streaming replication with a quorum expression, which deserves its own section.
The engine underneath has not stood still either. PostgreSQL 18 added an asynchronous I/O subsystem "that can improve performance of sequential scans, bitmap heap scans, vacuums, and other operations", B-tree skip-scan lookups, uuidv7() for timestamp-ordered identifiers, and OAuth authentication — changes that quietly retire the reasons a 2019 evaluation gave for staying put.
-- Postgres parity layer for a SQL Server migration.
--
-- THIS SCRIPT SPANS TWO CONNECTIONS, and the split is not cosmetic: pg_cron
-- "may only be installed to one database in a cluster" -- by convention the
-- maintenance database -- while the tables being audited live in the
-- application database. Run Part 1 in one and Part 2 in the other; neither
-- half completes from the other's connection.
--
-- PREREQUISITE NEITHER PART CAN SATISFY ITSELF: pgaudit and pg_cron must be
-- listed in shared_preload_libraries and the server RESTARTED before the
-- pg_cron and pgaudit CREATE EXTENSION statements below succeed (see the
-- postgresql.conf settings further down). A reload is not enough.
-- ===========================================================================
-- PART 1 -- connect to the MAINTENANCE database: psql -d postgres
-- ===========================================================================
CREATE EXTENSION IF NOT EXISTS pg_cron; -- one database per cluster
-- Cluster-wide, so they belong on exactly one connection: roles are
-- cluster-level objects, and ALTER SYSTEM writes postgresql.auto.conf for the
-- whole instance. pgaudit implements object-level auditing through the roles
-- system -- it logs statements touching objects the role named in
-- pgaudit.role has been granted rights on, so that grant list IS the evidence
-- scope you hand an assessor. The grants themselves are in Part 2, because
-- they have to be made where the tables are.
CREATE ROLE auditor NOLOGIN;
ALTER SYSTEM SET pgaudit.role = 'auditor';
ALTER SYSTEM SET pgaudit.log = 'ddl, role, write';
SELECT pg_reload_conf();
-- The SQL Server Agent equivalent. schedule_in_database() is how a job reaches
-- a database other than the one pg_cron is installed in.
SELECT cron.schedule_in_database(
job_name => 'nightly-vacuum-analyze',
schedule => '0 2 * * *',
command => 'VACUUM (ANALYZE) public.ledger_entries',
database => 'ledger'
);
-- The cron schema exists only in the database pg_cron was installed into, so
-- this readback belongs here and nowhere else.
SELECT jobid, jobname, schedule, database, active FROM cron.job ORDER BY jobid;
-- ===========================================================================
-- PART 2 -- reconnect to the APPLICATION database: psql -d ledger
-- ===========================================================================
-- Extensions are per-database objects, so these two are created again here,
-- in the database holding the tables being audited and the vectors being
-- searched. Do NOT add CREATE EXTENSION pg_cron to this part -- it is already
-- installed in the one database per cluster that may have it.
CREATE EXTENSION IF NOT EXISTS pgaudit;
CREATE EXTENSION IF NOT EXISTS vector; -- the pgvector project
-- The evidence scope, granted where the relations actually exist. Run these
-- against the maintenance database instead and both fail with "relation does
-- not exist".
GRANT SELECT, INSERT, UPDATE, DELETE ON public.ledger_entries TO auditor;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.customer_tokens TO auditor;# postgresql.conf -- EXCERPT. Only the settings this article argues about.
# ...
# Both pgaudit and pg_cron are preload libraries. Neither CREATE EXTENSION
# succeeds until this line is present and the server has been restarted --
# a reload is not enough.
shared_preload_libraries = 'pgaudit,pg_cron'
# ...
# The commit guarantee. ANY 1 of two listed standbys must acknowledge before
# the primary commits -- the analogue of requiring one synchronised secondary
# on an availability group.
synchronous_commit = on
synchronous_standby_names = 'ANY 1 (standby_a, standby_b)'
# ...
# WAL retention. wal_keep_size is the floor kept for any standby;
# max_slot_wal_keep_size is the backstop that stops a dead replication slot
# pinning WAL until the primary's volume fills.
wal_keep_size = 4GB
max_slot_wal_keep_size = 64GB
# ...
# Object-level audit logging. pgaudit.role names the role whose grants define
# the evidence scope; the log classes are what an assessor reads.
pgaudit.log = 'ddl, role, write'
pgaudit.log_relation = on
pgaudit.log_parameter = off # keep bound values out of the audit trail
# ...What does not port
Three things turn this migration into two projects, and none is a gap in the engine. Microsoft ships Integration Services and Analysis Services as separate server features beside the database engine — the first "a set of graphical tools and programmable objects for moving, copying, and transforming data", the second the tools for OLAP and data mining. The question is not whether Postgres has an equivalent, but whether your ETL and cubes belonged in the database decision at all. A hundred SSIS packages running the nightly load is a separate procurement — the same way choosing a migration tool is a decision about rollback and evidence, not syntax.
Distributed transactions are the second, and the scope matters. Microsoft's statement: "DTC isn't yet supported on Availability Groups on Linux. If your applications require the use of distributed transactions and need an AG, deploy SQL Server on Windows." That is about availability groups on Linux — not a claim that SQL Server on Linux has no distributed transactions, and silent about Windows.
Third is the procedural T-SQL surface, where Babelfish for PostgreSQL is the credible answer: it "understands the SQL Server wire-protocol and T-SQL ... so you don't have to switch database drivers or rewrite all of your application queries". There is no honest coverage percentage to quote. The real constraint is structural, and it is three sections below.
Replacing an availability group
The translation that matters is not "availability group becomes streaming replication" but two narrower questions: how many replicas must acknowledge before the primary commits, and who arbitrates a failover. Both engines answer both, with different numbers of machines.
On SQL Server the commit guarantee is REQUIRED_SYNCHRONIZED_SECONDARIES_TO_COMMIT, introduced in SQL Server 2017, which "sets a minimum number of synchronous secondary replicas required to commit before the primary replica commits a transaction" — default 0, maximum the replica count minus one. Arbitration is what costs hardware: "arbitration under Linux happens in SQL Server, where all the metadata is stored", and "having just a two-node configuration doesn't work for an AG." For quorum and automatic failover at cluster type External an availability group "either must: Have three synchronous replicas (SQL Server Enterprise edition only); or Have two replicas (primary and secondary) and a configuration-only replica." On Standard that is three replicas per group — primary, secondary, configuration-only — which for a single group means three instances.
-- Basic availability group, SQL Server Standard, Linux, cluster type External.
--
-- THREE instances, not two. The third holds the AG configuration in master and
-- none of the user databases. Per CREATE AVAILABILITY GROUP's
-- CONFIGURATION_ONLY option, that replica "Can be hosted on any edition of SQL
-- Server, including Express Edition" -- an instance and an endpoint to
-- operate, not a licence to buy.
--
-- PREREQUISITES THIS STATEMENT CANNOT SATISFY ITSELF. Pasted on its own it
-- errors instead of producing the group above. Per Microsoft's "Configure an
-- availability group for high availability on Linux":
-- 1. Always On enabled on EACH of the three instances, then a restart:
-- sudo /opt/mssql/bin/mssql-conf set hadr.hadrenabled 1
-- sudo systemctl restart mssql-server
-- 2. A certificate exchanged between the nodes and a DATABASE_MIRRORING
-- endpoint on every instance, on the port used below:
-- CREATE ENDPOINT [Hadr_endpoint] AS TCP (LISTENER_PORT = 5022)
-- FOR DATABASE_MIRRORING (ROLE = ALL,
-- AUTHENTICATION = CERTIFICATE dbm_certificate,
-- ENCRYPTION = REQUIRED ALGORITHM AES);
-- ALTER ENDPOINT [Hadr_endpoint] STATE = STARTED;
-- On the node hosting the configuration-only replica the role is WITNESS
-- -- on Express Edition it is the only valid value.
-- 3. [ledger] in FULL recovery with a valid log backup, because this
-- statement names it in FOR DATABASE:
-- ALTER DATABASE [ledger] SET RECOVERY FULL;
-- BACKUP DATABASE [ledger] TO DISK = N'/var/opt/mssql/data/ledger.bak';
-- A DIVERGENCE TO KNOW ABOUT: FOR DATABASE is valid grammar and the shape
-- the Windows examples use, but Microsoft's Linux examples all create the
-- group with FOR REPLICA ON and no database, then ALTER AVAILABILITY
-- GROUP [ag_ledger] ADD DATABASE [ledger] once the secondaries have
-- joined. Naming it here is what pulls this prerequisite forward.
--
-- AND AFTERWARDS: SEEDING_MODE = AUTOMATIC seeds nothing until the group is
-- granted the right to create databases -- on the primary immediately after
-- CREATE, and on each secondary once it has joined (a configuration-only
-- replica needs the join alone):
-- ALTER AVAILABILITY GROUP [ag_ledger] GRANT CREATE ANY DATABASE; -- primary
-- ALTER AVAILABILITY GROUP [ag_ledger] JOIN WITH (CLUSTER_TYPE = EXTERNAL);
-- ALTER AVAILABILITY GROUP [ag_ledger] GRANT CREATE ANY DATABASE; -- secondary
-- The Pacemaker resource is configured AFTER the group exists, and
-- FAILOVER_MODE = EXTERNAL does nothing until it is. That ordering -- install,
-- configure the AG, then configure the cluster resource manager -- is the
-- roadmap on a DIFFERENT page, "Configure a Pacemaker cluster for SQL Server
-- availability groups", and so is the fencing warning: that page's examples
-- "don't use fencing agents. They are for testing and validation only."
-- Production needs one.
CREATE AVAILABILITY GROUP [ag_ledger]
WITH (
BASIC,
CLUSTER_TYPE = EXTERNAL
)
FOR DATABASE [ledger]
REPLICA ON
N'sqlnode-a' WITH (
ENDPOINT_URL = N'tcp://sqlnode-a.db.example.com:5022',
AVAILABILITY_MODE = SYNCHRONOUS_COMMIT,
-- FAILOVER_MODE = EXTERNAL is documented in CREATE AVAILABILITY
-- GROUP's add_replica_spec; ALTER AVAILABILITY GROUP's reference
-- grammar lists FAILOVER_MODE = { AUTOMATIC | MANUAL } only. Set
-- it here, at CREATE time -- do not rely on an ALTER to set it.
FAILOVER_MODE = EXTERNAL,
SEEDING_MODE = AUTOMATIC
),
N'sqlnode-b' WITH (
ENDPOINT_URL = N'tcp://sqlnode-b.db.example.com:5022',
AVAILABILITY_MODE = SYNCHRONOUS_COMMIT,
FAILOVER_MODE = EXTERNAL,
SEEDING_MODE = AUTOMATIC
),
-- The arbitrating replica. No FAILOVER_MODE and no SEEDING_MODE
-- clause: those options "aren't supported when availability_mode is
-- set to configuration_only for a replica". Its mirroring endpoint
-- must be of type WITNESS, and the availability mode can't be altered later.
N'sqlnode-c' WITH (
ENDPOINT_URL = N'tcp://sqlnode-c.db.example.com:5022',
AVAILABILITY_MODE = CONFIGURATION_ONLY
);
GO
-- The commit guarantee. Postgres equivalent: synchronous_standby_names with
-- an ANY quorum count (see the next script).
--
-- CAVEAT, and it is the whole point of this comment: the value you set here is
-- not the value that persists. "For cluster type EXTERNAL the setting is
-- changed when the availability group is added to a cluster resource", and on
-- Linux "the value is configured automatically by the cluster on the AG
-- resource itself." Pacemaker owns this number. Read it back; do not assume it.
ALTER AVAILABILITY GROUP [ag_ledger]
SET (REQUIRED_SYNCHRONIZED_SECONDARIES_TO_COMMIT = 1);
GO
-- MODIFY REPLICA carries no FAILOVER_MODE clause here on purpose: these
-- replicas are EXTERNAL, and that value appears only in CREATE's documented
-- grammar. Leave the failover mode where CREATE put it.
-- Postgres equivalent of AVAILABILITY_MODE: synchronous_commit.
ALTER AVAILABILITY GROUP [ag_ledger]
MODIFY REPLICA ON N'sqlnode-b'
WITH (AVAILABILITY_MODE = SYNCHRONOUS_COMMIT);
GO
SELECT
ag.name AS availability_group,
ag.basic_features,
ag.cluster_type_desc,
ag.required_synchronized_secondaries_to_commit AS required_sync_secondaries,
ar.replica_server_name,
ar.availability_mode_desc,
ar.failover_mode_desc,
ar.secondary_role_allow_connections_desc AS secondary_readable
FROM sys.availability_groups AS ag
JOIN sys.availability_replicas AS ar
ON ar.group_id = ag.group_id
ORDER BY ag.name, ar.replica_server_name;
GOPostgres expresses the same guarantee as a setting rather than a topology. The `ANY` keyword in `synchronous_standby_names` "specifies a quorum-based synchronous replication and makes transaction commits wait until their WAL records are replicated to at least num_sync listed standbys" — so ANY 1 (standby_a, standby_b) is the direct analogue of requiring one synchronised secondary. The arbitration role that needed a third SQL Server instance moves to the operator, alongside fencing and leader election; those mechanics are covered in running PostgreSQL on Kubernetes with CloudNativePG.
-- Postgres half of the availability-group translation. Run as superuser.
-- Maps to REQUIRED_SYNCHRONIZED_SECONDARIES_TO_COMMIT = 1: commits wait until
-- the WAL is replicated to at least ONE of the two listed standbys. On a
-- SELF-MANAGED cluster the value lands in postgresql.auto.conf, which you own
-- and can diff -- and which overrides the same setting in the postgresql.conf
-- file shown earlier, so this statement is the one that wins. UNDER AN OPERATOR IT
-- DOES NOT: CloudNativePG's replication documentation states that "direct
-- configuration of the synchronous_standby_names option is not permitted",
-- and that the operator populates the option with the names of local pods,
-- customisable through the .spec.postgresql.synchronous stanza.
-- https://cloudnative-pg.io/docs/1.30/replication/
-- A separate default, documented on its PostgreSQL-configuration page and not
-- tied to this setting, disables ALTER SYSTEM on new clusters altogether:
-- every statement below needs .spec.postgresql.enableAlterSystem = true.
-- https://cloudnative-pg.io/docs/1.30/postgresql_conf/
-- Same question as Pacemaker owning the SQL Server value: find out which
-- layer writes the number, then read the number back from the engine.
ALTER SYSTEM SET synchronous_standby_names = 'ANY 1 (standby_a, standby_b)';
-- Maps to AVAILABILITY_MODE = SYNCHRONOUS_COMMIT.
ALTER SYSTEM SET synchronous_commit = 'on';
-- WAL retention for the standbys' replication slots. max_slot_wal_keep_size is
-- the backstop: a slot is allowed to pin this much WAL and no more.
ALTER SYSTEM SET wal_keep_size = '4GB';
ALTER SYSTEM SET max_slot_wal_keep_size = '64GB';
SELECT pg_reload_conf();
-- Read the resulting guarantee back: sync_state tells you which standbys are
-- actually counted toward the quorum right now, not which ones are listed.
SELECT
application_name,
state,
sync_state,
write_lag,
flush_lag,
replay_lag
FROM pg_stat_replication
ORDER BY sync_state, application_name;
-- And the slots, because an inactive slot is the failure mode, not the standby.
SELECT slot_name, active, wal_status, safe_wal_size
FROM pg_replication_slots
ORDER BY slot_name;Failure modes you inherit on each side
The edges are real and different. On SQL Server under Linux the first is layer confusion: cluster type External means "Pacemaker is used underneath the AG" and "requires that the failover mode is set to External as well" — a mismatch is a cluster that looks configured and will not fail over. Second, "quorum and fencing a failed node must be implemented properly from a Pacemaker perspective" — the database's health model does not save you from a split brain the cluster layer permitted. Third, what the script above flags: on Linux the cluster reconfigures the commit guarantee, so a T-SQL value never re-read is a guarantee you only believe you have. Alert on what you read back — a failover you have never rehearsed is a plan, not a capability.
On Postgres the dominant edge is the replication slot. A slot keeps the primary's WAL until the standby consumes it — fine until the standby goes away and stays away: WAL accumulates, the volume fills. max_slot_wal_keep_size is the designed backstop, pg_replication_slots.wal_status the signal: alert on an inactive slot with a shrinking safe_wal_size, not standby lag alone. The second edge fails the other way. synchronous_standby_names lists candidates; pg_stat_replication.sync_state reports which count now. A listed standby showing async — usually an application_name mismatch — has silently left the candidate set: the guarantee holds, on fewer machines than you think. Fall below num_sync counted standbys and commits wait rather than proceed unsynchronised — write latency and hung sessions, not silent loss. Alert on the count of sync_state in ('sync','quorum') and on commit wait time.
Audit evidence and the patched-Postgres problem
An assessor asks either engine the same two questions: show me privileged access is logged, and the data encrypted at rest. On SQL Server both answers are in Standard — transparent data encryption is a Yes in the editions table, and audit is native. On Postgres the first is community code and in-engine encryption at rest is not; volume encryption (LUKS, encrypted disks) clears the control without per-tablespace keys or protection from filesystem access. pgaudit is an unambiguous win on evidence quality: object-level logging keyed to a role makes that role's grant list *the* documented scope.
That is a trade, not a gotcha: a patched distribution with an open licence and a public modified tree beats a per-core proprietary one. It is still not stock community PostgreSQL, and the difference surfaces at upgrade time. Same shape as a licence-driven fork migration between search engines: the code is open; the question is whose release schedule you depend on.
Exit ramps and the long game
Published support horizons set the deadline, and one has already passed. SQL Server 2016's extended support ended on 14 July 2026 — a 2016 estate is not approaching a deadline, it is inside paid Extended Security Updates, Year 1 running to 13 July 2027. SQL Server 2017's extended support ends on 12 October 2027: one migration cycle for anything with an ETL layer. Both tables print an expiry a calendar day after the announced date — "7/15/2026 6:59:59 AM", "10/13/2027 6:59:59 AM" — beneath a header saying dates are Pacific, which does not reconcile. Plan against Microsoft's own announcement: "As of today, July 14, 2026, SQL Server 2016 has reached end of support." On 2016 you pay for time; on 2017 you spend it.
The Postgres side is a cadence, not a cliff: the project "releases a new major version containing new features about once a year" and "supports a major version for 5 years after its initial release". PostgreSQL 19 was still at Beta 4 when this was written in early October 2026, its announcement putting a release candidate in "early October" and noting "the PostgreSQL 19 GA may also occur in October" — so 18 is what you plan against today, a status worth re-checking.
The asymmetry is in the exits. Leaving SQL Server costs a schema and dialect conversion, an ETL re-platform and an HA rebuild. Leaving Postgres later costs the conversion and the HA rebuild — not the whole list, because the engine is not also your scheduler, ETL product and OLAP server. Consolidation is convenient until it is what you are undoing, and the arithmetic that makes a per-socket hypervisor licence hard to leave applies to a per-core database one.
So run the test, not the comparison. If the workload fits Standard's ceilings and tolerates per-database failover units, stay, and spend the difference on rehearsing the failover. If the only thing Enterprise buys you is a readable secondary and one failover unit across many databases, that is exactly the pair Postgres gives away. And if you move, decide whether you are moving to Postgres or to somebody's Postgres. Both are defensible. Only one ends the conversation.
§FAQ/Common questions
Frequently asked
Is Postgres better than SQL Server?
That is the wrong comparison, and it produces the wrong answer about half the time. The useful question has three outcomes. SQL Server 2025 Standard already includes transparent data encryption, columnstore, In-Memory OLTP, Query Store, Resource Governor and SQL Server Agent — all six are listed as available in Standard in Microsoft's own editions table, with columnstore and In-Memory OLTP footnoted as capped by the edition's memory limits rather than withheld — so most of what teams assume forces an Enterprise upgrade does not. Two things do: readable secondary replicas, which Microsoft's SQL Server on Linux availability-group documentation says are "only supported with SQL Server Enterprise edition", and availability-group coverage of more than one database, since Standard's basic availability group "supports two replicas, with one database". If neither of those binds, Standard is defensible. If both do, the real comparison is Enterprise at $15,123 per two-core pack against Postgres, where quorum synchronous replication and readable standbys are in community code.
How much does SQL Server 2025 cost per core?
Microsoft's published open no-level estimated retail prices are $15,123 per two-core pack for Enterprise and $3,945 per two-core pack for Standard, with subscription equivalents of $5,434 and $1,418 per year. Core SKUs are sold only in two-core packs, and licensing a physical server requires licences for every physical core with a minimum of four per physical processor. A two-socket, sixteen-physical-core server is therefore eight packs: $31,560 on Standard and $120,984 on Enterprise. Microsoft states on the price sheet that these are open no-level estimated retail prices and directs customers to a reseller for their specific pricing, so treat any figure derived from them as a model rather than a quote. Note also which core count you are using: per-VM licensing counts virtual cores plus every hardware thread they map to, requires Software Assurance or a subscription, and is a different rule from the physical-core count above.
Can SQL Server Standard fail over multiple databases as a single unit?
Yes — with a failover cluster instance, not with availability groups. Standard supports Always On failover cluster instances on two nodes (Enterprise supports sixteen), and an FCI "is redundant at the server-instance level", so every database on the instance fails over together without an Enterprise licence. The per-database failover unit is a property of basic availability groups, which support "two replicas, with one database". The FCI branch has two real costs. First, it "must use shared storage among all nodes of the FCI for database and log storage", and Microsoft notes "the shared storage has the potential of being the single point of failure" — one copy of the data, and no readable secondary. Both come from the Windows-scoped FCI page; the Linux shared-disk cluster concepts page documents the same arrangement. Second, it is not licence-free: Microsoft states that licences without Software Assurance or a subscription "do not include disaster recovery or high availability rights, including use of fail-over clusters", so without SA the passive node needs its own licences — a second $31,560 at list price on a sixteen-core server. Even with SA the benefit is one passive high-availability replica per licensed OSE, no larger than the primary.
Why does a SQL Server availability group on Linux need three instances?
Because arbitration happens inside SQL Server rather than at the cluster layer. Microsoft states that "arbitration under Linux happens in SQL Server, where all the metadata is stored" and that "having just a two-node configuration doesn't work for an AG". To maintain quorum and enable automatic failovers at cluster type External, an availability group must have either three synchronous replicas — Enterprise edition only — or two replicas plus a configuration-only replica. That third instance stores the availability-group configuration in master, holds none of the user databases, and receives its configuration data synchronously from the primary. Crucially for a licensing decision, the CREATE and ALTER AVAILABILITY GROUP reference pages both document that this configuration-only replica "can be hosted on any edition of SQL Server, including Express Edition", so it costs an instance and an endpoint to operate but not a licence. An FCI is different: all FCI failover arbitration happens at the cluster layer, so Microsoft notes that the quorum mechanisms provided by Pacemaker "can be fine" there — which is not the same as saying fencing needs no design.
What is the Postgres equivalent of REQUIRED_SYNCHRONIZED_SECONDARIES_TO_COMMIT?
A quorum expression in synchronous_standby_names. PostgreSQL's documentation states that "the keyword ANY, coupled with num_sync, specifies a quorum-based synchronous replication and makes transaction commits wait until their WAL records are replicated to at least num_sync listed standbys", so ANY 1 (standby_a, standby_b) is the direct analogue of requiring one synchronised secondary before commit. On the SQL Server side, REQUIRED_SYNCHRONIZED_SECONDARIES_TO_COMMIT "sets a minimum number of synchronous secondary replicas required to commit before the primary replica commits a transaction", with a default of 0 and a maximum of the replica count minus one. One practical difference is worth carrying into the migration: at cluster type EXTERNAL that setting "is changed when the availability group is added to a cluster resource", and on Linux "the value is configured automatically by the cluster on the AG resource itself" — so the cluster owns it. In self-managed Postgres the value stays where you put it. Under an operator it does not: CloudNativePG's replication documentation (cloudnative-pg.io/docs/1.30/replication/) states that "direct configuration of the synchronous_standby_names option is not permitted" and that the operator populates the option itself with the names of local pods, customisable through the .spec.postgresql.synchronous stanza. A separate default on its PostgreSQL-configuration page, not tied to this setting, disables ALTER SYSTEM on new clusters altogether unless .spec.postgresql.enableAlterSystem is set to true. So the question is the same on both engines — find out which layer writes the number — and either way pg_stat_replication.sync_state tells you which standbys are actually counted right now.
Does moving to Postgres always mean moving to community PostgreSQL?
No, and conflating the two is a planning error that surfaces late. The two headline parity answers for a SQL Server estate both require a patched build. Percona's pg_tde, which brings transparent data encryption to Postgres, "is bundled as a component of Percona Server for PostgreSQL and requires its patches to function". Babelfish for PostgreSQL, which understands the SQL Server wire protocol and T-SQL, carries extensions that "depend on patches to community PostgreSQL". Neither is proprietary — the Babelfish extensions are licensed under the Apache License 2.0, with a PostgreSQL-licence file in the same branch — so the source is auditable and forkable, which is a materially better position than a per-core proprietary licence. It is still a distribution dependency rather than stock community code, and it shows up at upgrade time. Decide which project you are running before you start, not during the first major-version upgrade.
Further reading
- Postgres vs MariaDB: Replication, Upgrades, On-Call
- PostgreSQL on Kubernetes with CloudNativePG
- Database Migration Tools: Rollback, Review and Evidence
- OpenSearch vs Elasticsearch: Benchmarks, Drift, Licence
- Vendor Lock-In in the Cloud: Pricing Your Exit as a Number
- KubeVirt: The VMware Exit for VMs You Cannot Containerise
- IT Disaster Recovery Testing Without a Maintenance Window
- Postgres vs MongoDB: You May Already Have a Document Store
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.