
Data
Postgres vs MariaDB: Replication, Upgrades, On-Call
Postgres vs MariaDB turns on operations, not features: how a major upgrade runs, which engine does synchronous multi-primary, and how long each is supported.
Search this comparison and you get capability tables: JSONB against JSON, window functions, whose full-text search wins. They answer a question nobody is asking — both engines will serve your application for a decade. What differs is the work after.
Capability Is Not the Axis
Start with the number you can compute before lunch, because it is already decided for you. The PostgreSQL Global Development Group *supports a major version for 5 years after its initial release*, then ships *a final minor version* — flat, identical for every major. MariaDB's community binaries used to match and no longer do: MariaDB Community LTS binaries *are released for 3 years after the GA date*, where *for releases up to MariaDB 11.4, the binaries are released for 5 years*.
The change left a visible seam. 11.4 (GA 29 May 2024) carries a community end-of-maintenance date of 29 May 2029; its successor 11.8 (GA 4 June 2025) carries 4 June 2028 — just under a year *earlier*. On the community column the older LTS outlasts the newer one. Read the Enterprise and Extended columns and the order flips back, which is why the column has to be named whenever one of these dates is quoted.
Two axes diverge, and neither is in a feature matrix: how a major upgrade runs, and what shape a replication topology may take. Types, indexes, behaviour under an orchestrator — that all moves under you anyway.
One short-circuit is worth testing first. MariaDB Server was *originally designed as enhanced, drop-in replacement for MySQL*, and its connection protocol is backward compatible — *you don't normally need to upgrade any of your old clients*. An estate already speaking the MySQL wire protocol is a real reason to look at MariaDB first, but check the scope: the documentation covers *any MySQL version < 8.0 to MariaDB 10.6 and up*. MySQL 8.0 and later is not in it.
The Major Upgrade Is the Biggest Difference
Write both runbooks first: the difference is not minutes of downtime, it is what kind of event an upgrade *is*. The standby you already run cannot carry Postgres across a major — in general, *log shipping between servers running different major PostgreSQL release levels is not possible*. Two paths follow. `pg_upgrade` works because major releases *often change the layout of the system tables, but the internal data storage format rarely changes*: new system tables, old data files reused, both clusters down. It *does not transfer all statistics*: you *will be instructed to run* vacuumdb --all --analyze-in-stages --missing-stats-only then vacuumdb --all --analyze-only.
The second path is logical replication, among PostgreSQL's own typical use cases — *replicating between different major versions of PostgreSQL*. The subscriber catches up while the old primary serves, shrinking the window to the flip. But it carries rows, not a database. Schema and DDL are not replicated; the initial schema is *copied by hand using pg_dump --schema-only*. *Sequence data is not replicated* — a sequence *would still show the start value on the subscriber*, so each is reset by hand or the first insert collides — and *large objects are not replicated*, with *no workaround for that, other than storing data in normal tables*.
On MariaDB the step that bites comes before the swap: shut the server down cleanly, *because even if data files are compatible between versions, recovery logs may not be*. innodb_fast_shutdown must not be 2, innodb_force_recovery must be less than 3. A crash-stopped server turns a binary swap back into a restore, with a different RTO.
#!/usr/bin/env bash
# Postgres major-upgrade preflight. Run it days before the window, never in it.
# Nothing here mutates either cluster.
set -euo pipefail
OLD_BIN=/usr/lib/postgresql/17/bin
NEW_BIN=/usr/lib/postgresql/18/bin
OLD_DATA=/var/lib/postgresql/17/main
NEW_DATA=/var/lib/postgresql/18/main
OLD_PORT=5432
NEW_PORT=50432 # pg_upgrade's own default; must differ from OLD_PORT
SUPERUSER=postgres
INVENTORY=/var/tmp/pg-extensions-in-use.txt
# Prerequisite for step 3, and the usual reason --check fails on a first run:
# NEW_DATA must ALREADY exist, initdb'd with flags compatible with the old
# cluster, and must NOT be running. The manual says there is no need to start
# the new cluster. A distribution package that created AND started
# /var/lib/postgresql/18/main satisfies the first half and breaks the second,
# so stop that service before running this.
# 1 — Enumerate what the old cluster actually loads, in every database.
# plpgsql is built in and has no shared object of its own.
: > "$INVENTORY"
while read -r db; do
"$OLD_BIN/psql" -U "$SUPERUSER" -p "$OLD_PORT" -d "$db" -At -F'|' \
-c "SELECT current_database(), extname, extversion
FROM pg_extension
WHERE extname <> 'plpgsql'
ORDER BY extname" >> "$INVENTORY"
# datallowconn is filtered here because psql has to connect to read
# pg_extension; template0 refuses connections and holds no extensions.
# The wraparound query later in this article must NOT filter it.
done < <("$OLD_BIN/psql" -U "$SUPERUSER" -p "$OLD_PORT" -At \
-c "SELECT datname FROM pg_database WHERE datallowconn")
sort -u -o "$INVENTORY" "$INVENTORY"
cat "$INVENTORY"
# 2 — Confirm a build for the NEW major is installed for each one. The manual
# requires shared object files matching the new server binary to be present
# in the new cluster. Who packaged them is irrelevant; their presence is
# not. The library is not always named after the extension — PostGIS
# installs postgis-3.so — so read module_pathname out of the new cluster's
# control file and fall back to the extension name only when there is none.
NEW_LIBDIR="$("$NEW_BIN/pg_config" --pkglibdir)"
NEW_SHAREDIR="$("$NEW_BIN/pg_config" --sharedir)"
resolve_lib() {
sed -n "s|^[[:space:]]*module_pathname[[:space:]]*=[[:space:]]*'\$libdir/\([^']*\)'.*|\1|p" \
"$NEW_SHAREDIR/extension/$1.control" 2>/dev/null | head -n1
}
suspect=0
while read -r ext; do
lib="$(resolve_lib "$ext" || true)"
[ -n "$lib" ] || lib="$ext"
if [ -f "$NEW_LIBDIR/$lib.so" ]; then
printf 'ok %-14s %s\n' "$ext" "$NEW_LIBDIR/$lib.so"
else
printf 'SUSPECT %-14s %s not found\n' "$ext" "$NEW_LIBDIR/$lib.so"
suspect=1
fi
done < <(cut -d'|' -f2 "$INVENTORY" | sort -u)
# This is a heuristic, not the gate: an extension may ship several libraries
# under names no control file announces. So it warns and keeps going. Step 3,
# pg_upgrade --check, is the authority, and it is also non-mutating.
if [ "$suspect" -ne 0 ]; then
echo "resolve the SUSPECT lines before the window opens" >&2
fi
# 3 — Then ask pg_upgrade itself. --check performs only the checks, and does so
# even while the old server is still running, so this costs no downtime.
"$NEW_BIN/pg_upgrade" \
--old-bindir="$OLD_BIN" --new-bindir="$NEW_BIN" \
--old-datadir="$OLD_DATA" --new-datadir="$NEW_DATA" \
--old-port="$OLD_PORT" --new-port="$NEW_PORT" \
--username="$SUPERUSER" --check
# 4 — Post-upgrade, not preflight. pg_upgrade transfers most optimizer
# statistics but not all of them, and instructs you to run these two at
# the end. It prints them WITHOUT --jobs; the docs note separately that
# --jobs can speed them up, so the flag below is ours, not its output.
cat <<'NOTE'
after the upgrade, against the NEW cluster (pg_upgrade's own two commands,
plus --jobs, which the upgrade docs offer to speed them up):
vacuumdb --all --analyze-in-stages --missing-stats-only --jobs=4
vacuumdb --all --analyze-only --jobs=4
NOTE#!/usr/bin/env bash
# MariaDB major-upgrade runbook. Unlike the Postgres preflight above, this
# MUTATES: it stops the server and replaces packages. Run it in the window.
# The data directory is carried through untouched.
set -euo pipefail
BACKUP_DIR=/var/backups/mariadb
IS_GALERA_NODE=no # yes on a Galera node moving to 12.3 or later
PKG_MANAGER=apt # apt | rpm
TARGET_SERIES=12.3 # the major you are moving TO, checked in step 4
# 1 — Storage engines. MariaDB's upgrade page: ensure the target version
# supports the storage engines you are using. Its own example is TokuDB,
# not supported in 10.6 and later. This is MariaDB's extension gate.
mariadb -NBe "SELECT DISTINCT engine
FROM information_schema.tables
WHERE table_schema NOT IN ('information_schema',
'performance_schema','mysql','sys')
AND engine IS NOT NULL"
# 2 — Back up the system database, as the page instructs.
mkdir -p "$BACKUP_DIR"
mariadb-dump --add-drop-table mysql > "$BACKUP_DIR/mysql-system-$(date +%F).sql"
# 3 — Shutdown settings, ENFORCED before the stop rather than printed and
# ignored. Even when data files are compatible across versions, recovery
# logs may not be. innodb_fast_shutdown must not be 2 (the default is 1);
# innodb_force_recovery must be less than 3.
fast_shutdown="$(mariadb -NBe "SELECT @@global.innodb_fast_shutdown")"
force_recovery="$(mariadb -NBe "SELECT @@global.innodb_force_recovery")"
printf 'innodb_fast_shutdown=%s innodb_force_recovery=%s\n' \
"$fast_shutdown" "$force_recovery"
if [ "$force_recovery" -ge 3 ]; then
echo "ABORT: innodb_force_recovery=$force_recovery; fix that first" >&2
exit 1
fi
if [ "$fast_shutdown" -eq 2 ]; then
# 2 is a fast crash shutdown — exactly the state that leaves recovery logs
# the new binaries may not read. The variable is dynamic, so lower it and
# only then stop. Do not skip straight to systemctl stop.
mariadb -NBe "SET GLOBAL innodb_fast_shutdown = 1"
fi
# 4 — Repository gate. Step 6 installs whatever the configured repository
# resolves, and MariaDB package names carry no major version, so a paste
# that skipped mariadb_repo_setup reinstalls the major already running
# and spends the window for nothing. Check the candidate BEFORE the
# server goes down, and fail closed.
case "$PKG_MANAGER" in
apt)
apt-get update
candidate="$(apt-cache policy mariadb-server |
awk '/Candidate:/ { print $2 }')"
;;
rpm)
candidate="$(dnf -q repoquery --qf '%{VERSION}\n' MariaDB-server |
sort -V | tail -1)"
;;
esac
# A Debian version string is not an upstream one. MariaDB's own noble index
# versions mariadb-server with an epoch and a packaging suffix — the shape is
# 1:<upstream>+maria~ubu2404, and the patch digit moves — so comparing the raw
# candidate against 12.3 aborts a correctly configured host. Strip the epoch
# (everything through the first ':') and the packaging suffix (from the first
# '+' or '-'), then compare what is left. Still fail closed.
upstream="${candidate#*:}"
upstream="${upstream%%[-+]*}"
case "$upstream" in
"$TARGET_SERIES".* | "$TARGET_SERIES") : ;;
*)
echo "ABORT: repository offers '$candidate' (upstream $upstream)," \
"not $TARGET_SERIES" >&2
exit 1
;;
esac
# 5 — Clean shutdown. The step that gets skipped, and the one that turns a
# binary swap back into a restore.
systemctl stop mariadb
# 6 — Swap the binaries, preferably without starting the server. Install with
# ONE package manager: the Galera package has to come from the same one
# as the server.
case "$PKG_MANAGER" in
apt)
apt-get install -y mariadb-server
if [ "$IS_GALERA_NODE" = yes ]; then
# From 12.3 the automatic dependency has been removed. Without this the
# cluster may fail to bootstrap after an otherwise standard upgrade.
apt-get install -y mariadb-server-galera
fi
;;
rpm)
# rpms do not support upgrading between major versions, only minor, so
# de-install the old MariaDB rpms and install the new ones — plural, and
# the same set. The server package is rarely the only one: client,
# backup, engine and plugin packages all come off with the wildcard
# removal, and installing MariaDB-server alone does not put them back —
# which exits 0 on a host that has just lost its backup tooling. Package
# names carry no major version, so the recorded set is what the new
# repository resolves. No --nodeps — a forced removal leaves the rpm
# database describing an install you no longer have.
mapfile -t pkgs < <(rpm -qa --qf '%{NAME}\n' 'MariaDB-*' | sort -u)
if [ "${#pkgs[@]}" -eq 0 ]; then
echo "ABORT: no MariaDB-* rpms installed; wrong branch for this host" >&2
exit 1
fi
if [ "$IS_GALERA_NODE" = yes ] &&
! printf '%s\n' "${pkgs[@]}" | grep -qx MariaDB-server-galera; then
# From 12.3 the automatic dependency has been removed. Without this the
# cluster may fail to bootstrap after an otherwise standard upgrade.
pkgs+=(MariaDB-server-galera)
fi
printf 'reinstalling: %s\n' "${pkgs[*]}"
yum remove -y 'MariaDB-*'
yum install -y "${pkgs[@]}"
;;
esac
# 7 — Do the actual work. MariaDB's own step: if the server was not started
# as part of the package install, start it with --skip-grant-tables. The
# warnings about system tables not being up to date are expected —
# mariadb-upgrade is what fixes them. --user is explicit: starting
# mariadbd as root with it unset is fatal unless a packaged my.cnf
# happens to set user=mysql, and a dead server here looks like a slow one.
# The trap is the whole point of this step. mariadbd below runs with
# --skip-grant-tables, which means no authentication. Under set -e a
# non-zero mariadb-upgrade exits the shell before step 8 ever runs, and
# that server keeps serving with authentication off. Arm the shutdown
# before starting it, not after.
mariadbd_pid=
shutdown_temp_server() {
[ -n "$mariadbd_pid" ] || return 0
local pid="$mariadbd_pid"
mariadbd_pid=
mariadb-admin shutdown 2>/dev/null || kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
}
trap shutdown_temp_server EXIT
started_by_package=yes
if ! systemctl is-active --quiet mariadb; then
started_by_package=no
mariadbd --user=mysql --skip-grant-tables &
mariadbd_pid=$!
ready=no
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
if mariadb-admin ping --silent 2>/dev/null; then
ready=yes
break
fi
sleep 1
done
if [ "$ready" = no ]; then
echo "ABORT: mariadbd did not answer ping in 20s; read the error log" >&2
exit 1 # the EXIT trap stops it
fi
fi
mariadb-upgrade
# 8 — Restart under the service manager. Clients normally need nothing: the
# MariaDB connection protocol is backward compatible.
if [ "$started_by_package" = no ]; then
shutdown_temp_server
fi
systemctl restart mariadbReplication Topology Decides Who Is on Call
Postgres's built-in answer is primary and standby. Table 26.1 prices it in two adjacent rows: for write-ahead log shipping, *no waiting for multiple servers* with sync off, and *primary failure will never lose data* with sync on.
Need more than one writer and the documentation's own sentence is narrower than either side of the argument: *PostgreSQL does not offer this type of replication, though PostgreSQL two-phase commit (PREPARE TRANSACTION and COMMIT PREPARED) can be used to implement this in application code or middleware.* That is about synchronous multi-primary and nothing more: the same table marks *Allows multiple primary servers* for logical replication, and leaves *No conflict resolution necessary* blank.
So core Postgres does have a multi-writer path: *a server that is publishing its own changes can also subscribe to changes from another server, allowing data to flow in multiple directions*. Per table, asynchronous when mutual (a one-way subscriber can be synchronous), under a conflict policy you cannot configure — three outcomes, only the first of which raises an error. (Its opening sentence — *if incoming data violates any constraints the replication will stop* — means a subscriber-side CHECK violation halts the apply worker too, outside the seven named types.)
- Error, raised until the conflict is resolved manually —
insert_exists,update_exists,multiple_unique_conflicts: theNOT DEFERRABLEunique-constraint cases. The apply worker stops until you clear it withALTER SUBSCRIPTION ... SKIP (lsn = '<finish LSN>'), or withpg_replication_origin_advance()on a disabled subscription — and skipping a whole transaction can leave the subscriber inconsistent. - Always applied regardless of the origin of the local row —
update_origin_differs,delete_origin_differs, detectable only withtrack_commit_timestampon. - Simply skipped, no error —
update_missing,delete_missing. *The update will simply be skipped in this scenario.*
MariaDB's default is asynchronous too, in its own words: *replicas request events from the primary's binary log whenever the replicas are ready. The primary does not wait for a replica to confirm that an event has been received.* Semisync is *built into the server and is always available*, falling back to asynchronous after a timeout.
Galera is the capability with no core-Postgres equivalent: write-sets certified across nodes at commit, every node writing. It bills twice. The first bill is schema, and the known-limitations page is blunt. *Replication works only with the InnoDB storage engine*. *All tables should have a primary key*, and *DELETE operations are unsupported on tables without a primary key*. *Unsupported explicit locking include LOCK TABLES*. *The general query log and the slow query log cannot be directed to a table* — set log_output=FILE. One number there is stale. *A write set is processed as a single memory-resident buffer* bounded by wsrep_max_ws_rows and wsrep_max_ws_size, which that page still says *limit transaction rows to 128K and the transaction size to 2Gb by default*. The system-variables reference gives them a *Default Value: 0* and a *Default Value: 2147483647 (2GB)*, the top of its *1024 to 2147483647* range, and the 11.4, 11.8 and 12.3 source agrees: no row cap out of the box, and a size cap that is also the maximum.
The second bill never closes, and no feature table carries it. The same page's *Consistency and Isolation Anomalies* section says Galera runs *a distributed variant of Snapshot Isolation* whose optimistic concurrency control is *subject to certain transactional phenomena even in healthy clusters without hardware or network faults*. Two of them: Galera *does not implement a "first-committer-wins" policy for all operation types*, so *standard read-modify-write patterns common in many ORMs are unsafe without explicit locking (e.g., using SELECT ... FOR UPDATE)*; and a commit acknowledged on one node may not be seen by a transaction starting on another, so *users requiring real-time visibility must use the wsrep_sync_wait session variable*. The first is application code, in every ORM you own; the second is configuration, per session, forever.
# mariadb-operator — synchronous multi-primary, the topology core Postgres
# has no equivalent for. The operator is MIT-licensed and offers both
# asynchronous replication and synchronous multi-master via Galera.
apiVersion: k8s.mariadb.com/v1alpha1
kind: MariaDB
metadata:
name: ledger
namespace: data
spec:
image: mariadb:12.3
replicas: 3
rootPasswordSecretKeyRef:
name: mariadb-root
key: password
username: ledger
database: ledger
passwordSecretKeyRef:
name: mariadb-ledger
key: password
galera:
enabled: true
primary:
podIndex: 0
autoFailover: true
recovery:
enabled: true
minClusterSize: 1
clusterMonitorInterval: 10s
clusterHealthyTimeout: 30s
clusterBootstrapTimeout: 10m
podRecoveryTimeout: 5m
podSyncTimeout: 5m
storage:
size: 100Gi
# Example class — local-path ships with k3s. On a cluster without it the
# PVC stays Pending. Substitute whatever your cluster actually provides.
storageClassName: local-path
myCnf: |
[mariadb]
# Galera cannot direct the general or slow query log to a table.
log_output = FILE
slow_query_log = ON
slow_query_log_file = /var/lib/mysql/slow.log
# Write-set bounds. These are NOT the server defaults: on current MariaDB
# wsrep_max_ws_rows defaults to 0 (no row cap) and wsrep_max_ws_size
# defaults to 2147483647 (2GB), which is also the top of its documented
# range (1024 to 2147483647). The size line below therefore pins the
# real default; the row line is a deliberate
# tightening, so a runaway bulk load fails loudly here instead of
# certifying a write set the size of the buffer.
wsrep_max_ws_rows = 131072
wsrep_max_ws_size = 2147483647
service:
type: ClusterIP
connection:
secretName: ledger-connNothing in that manifest enforces a primary key, an InnoDB engine, a bounded transaction or a locked read-modify-write. Those live in your schema and your application — which is why both bills belong before the decision, the same discipline that makes a failover rehearsal honest.
Extensions Versus Storage Engines and Plugins
Both engines ask one question in two vocabularies: a major upgrade is gated on version-compatible plug-in code. Postgres puts capability in extensions: pgvector *supports Postgres 13+*, enabled per database with CREATE EXTENSION vector, and pgaudit likewise. Before pg_upgrade runs, *shared object files matching the new server binary must be installed in the new cluster*. Who built them is not the gate — pgvector installs *with Docker, Homebrew, PGXN, APT, Yum, pkg, APK, or conda-forge* — only a build for the target major is, which makes an extension-resident vector store a different planning problem than a standalone one. pg_upgrade --check will *perform only the checks, even if the old server is still running*, so the gate fires before the window.
MariaDB asks the identical question of storage engines: *ensure that the target MariaDB version supports the storage engines you are using. For example, TokuDB is not supported in MariaDB 10.6 and later.* No per-major rebuild to track — instead, a table you can no longer open. Either way the check costs five minutes while the old server still serves, and each script above opens with its own inventory: pg_extension on Postgres, information_schema.tables engines on MariaDB.
What Starts Hurting in Year Two
Postgres's standing maintenance tax is vacuum. An UPDATE or DELETE leaves the old row version behind; the space it occupies *must then be reclaimed for reuse by new rows, to avoid unbounded growth of disk space requirements*, and *this is done by running VACUUM*. Autovacuum handles it until a workload shape defeats it.
The hard edge behind it is transaction ID wraparound. *It is necessary to vacuum every table in every database at least once every two billion transactions.* Ignore the warnings and *the system will refuse to assign new XIDs once there are fewer than three million transactions left until wraparound*, after which *only read-only transactions can be started*. A write outage with a deterministic cause and a published counter — one of the few you can alert on years ahead.
-- Per-database transaction age against the wraparound horizon.
-- The age() expression and the unfiltered FROM pg_database are the manual's
-- own monitoring query. Do not add WHERE datallowconn: template0 has
-- datallowconn false, is the database this check most often misses, and its
-- age still counts toward the horizon, because the rule is every table in
-- every database.
-- The headroom and percentage columns are this report's arithmetic on top of
-- the manual's query. Read them as distance to the two-billion vacuuming rule,
-- NOT as transactions until the server stops writing: that second, tighter
-- limit is the documented failsafe at fewer than three million transactions
-- left until wraparound. Alert on both, with different thresholds.
SELECT datname,
datallowconn,
age(datfrozenxid) AS xid_age,
2000000000::bigint - age(datfrozenxid) AS headroom_to_2bn,
round(100.0 * age(datfrozenxid) / 2000000000, 1) AS pct_of_2bn,
round(100.0 * age(datfrozenxid)
/ current_setting('autovacuum_freeze_max_age')::bigint, 1)
AS pct_of_freeze_max
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
-- And the relation holding the database back, which is the one you will
-- actually have to do something about. This query is the manual's, verbatim
-- apart from the ordering and the limit.
SELECT c.oid::regclass AS table_name,
greatest(age(c.relfrozenxid), age(t.relfrozenxid)) AS age
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relkind IN ('r', 'm')
ORDER BY 2 DESC
LIMIT 20;Connection architecture is the other year-two surprise, and the one most often stated backwards. Both default to one connection per execution context: the PostgreSQL server *starts ("forks") a new process for each connection*, and MariaDB on Unix runs one thread per connection, because `thread_handling` must be *set to pool-of-threads in a server option group in an option file prior to starting up the server* — pool-of-threads is the preset default only on Windows. Neither pools out of the box on Linux.
The asymmetry is reachability, not pooling. MariaDB's thread pool is *built in, not a plugin* — one configuration line and a restart, with a documented cost: *when the thread pool is enabled on a busy server, even simple queries might be queued to be executed later*. Postgres has none in core, so the capability arrives as one more thing you run, monitor and upgrade: a long-game cost, not a benchmark one.
One default belongs in the migration plan, not an incident review: *Read Committed is the default isolation level in PostgreSQL*, while REPEATABLE READ *is the default isolation level for InnoDB*. Defaults, not equivalences — verify what your application relies on.
What an Auditor Is Actually Shown
MariaDB's encryption model is in-server and explicit. You *must install and configure a Key Management Plugin* *before enabling encryption options for storage engines* — no plugin, no encryption, which is an artefact an assessor can be shown. It reaches past the tablespace: *MariaDB can encrypt binary logs and relay logs to protect the replication pipeline*. What it does not cover is on the same page, and belongs in the control narrative: *information in .frm files and the system data dictionary*, and *the MariaDB Error Log, General Query Log, and Slow Query Log*.
PostgreSQL's Encryption Options chapter offers two paths and names the weakness of each. *Storage encryption can be performed at the file system level or the block level* — eCryptfs, *dm-crypt + LUKS* — but *does not protect against attacks while the file system is mounted, because when mounted, the operating system provides an unencrypted view of the data*. The column path is pgcrypto, where *the decrypted data and the decryption key are present on the server for a brief time* and *can be intercepted by someone with complete access to the database server*.
Audit logging splits the same way, and neither side is free. pgaudit *provides detailed session and/or object audit logging via the standard logging facility provided by PostgreSQL* — a separate project, one more shared object built per major. MariaDB's audit plugin *records server activity* and its library *is included in MariaDB packages*, but the plugin *is not actually installed by MariaDB by default* and still needs INSTALL SONAME 'server_audit'. What differs is how many parties you must trust for the control, the same calculus as keeping secrets outside the cluster's own trust boundary.
The Support Clock and Whose Binaries You Run
Now compute the date for the version you are about to install. Postgres is one rule: five years from initial release, then a final minor. PostgreSQL 18, first released 25 September 2025, has a final release scheduled for 14 November 2030; PostgreSQL 14, first released 30 September 2021, reaches its final release on 12 November 2026 — next month, as this is published. As of 12 October 2026, 18 is the newest GA major: the release team's schedule announcement of 28 September 2026 puts *the GA release* of 19 on *Thursday, Oct 29, 2026*.
MariaDB publishes three clocks on one page, and the LTS one needs stating precisely because the sloppy version is wrong both ways. Community LTS binaries run three years, after which *critical and security fixes will be provided in source code releases for 2 additional years*. MariaDB is not unsupported at year three — code maintenance runs to five. But the free prebuilt binaries have stopped, so from year four you compile your own, take a distribution's, or buy enterprise: a staffing decision with a date on it.
Read the clock off the current LTS, not the inversion above it: 12.3 went GA 28 May 2026, community end of maintenance 12 June 2029, Enterprise and Extended still published as TBC.
The third clock is the trap: the newest-looking MariaDB versions are not LTS. As published on 12 October 2026, the rolling table lists 13.1 (Sep 2026, Community EOL Q1 2027) and 13.0 (May 2026, Community EOL Q4 2026). The branch does get patched inside that window — the *c* number *grows with each bug fix release, scheduled quarterly*, and 13.0 carries both a 13.0.1 and a 13.0.2 tag. So the treadmill is the end date, not a missing patch: install the newest-numbered MariaDB and the clock is one of those quarters, on a branch that was never an LTS.
Which leaves the question only you can answer: whose binaries are you running? Upstream, your distribution, or a commercial vendor — each has its own end date, and that one governs you. The Foundation is direct about its own:
These dates constitute the public policy of the MariaDB Foundation and are not legally binding. The software is released with the GPLv2 license as-is, without warranties.
Exit Ramps and the Decade View
Price the reversal before you commit; the directions are not symmetrical. MariaDB to Postgres has an actively developed open-source path. pgloader *handles both the schema and data parts of the migration, in a single unmanned command*, and its own site names MySQL and MariaDB as *the flagship path*. Read its limitations against the 3.x line they document — *views are not migrated*, *triggers are not migrated*, and *of the geometric datatypes, only the POINT database has been covered* — hand-rewrites, cheap to scope now and expensive mid-cutover.
Read the release state too: the current stable release is 3.6.9, from October 2022, and the only newer artefact is a prerelease tagged v4-dev, a Clojure rewrite built from main. Plan against what is released, and treat it as one input into a migration plan with a rollback and an evidence trail. In reverse the asymmetry shows: the maintained open-source converters run MySQL and MariaDB *into* Postgres, not out, and an October 2026 search surfaced no Postgres-to-MariaDB engine converter of pgloader's class. Treat that as a dated result, not proof of absence — confirm it before you lean on it, and price commercial migrators separately.
Extensions do not travel either: capability placed in pgvector or pgaudit needs a build per major and does not port to another engine. MariaDB shipped vector search in the server itself, *with MariaDB Vector as the lead new feature* of 11.8. Both being open source makes this a different decision from weighing a proprietary engine's edition tiers or escaping a document store, though PostgreSQL's *liberal Open Source license* and MariaDB's *GPL license, version 2* differ in kind if you embed the engine in what you ship.
Hiring is the slowest-moving variable. The Stack Overflow 2026 Developer Survey puts PostgreSQL at 6,984 respondents (57.9%) against MariaDB's 2,446 (20.3%), MariaDB sixth — a hiring-pool signal, not evidence that either engine is better.
Over ten years you are not choosing a better database but which bill of work to hold. Postgres asks for a cutover-shaped upgrade with a prerequisite build, a vacuum discipline with a published counter, and a pooler you source yourself — for a flat five-year clock and the deepest hiring pool. MariaDB asks for a packaging decision at year three, and — if you want Galera — a schema audit once plus a standing duty to lock read-modify-write and set wsrep_sync_wait: for an upgrade that is a restart, and a synchronous multi-primary topology core Postgres does not offer. Write down which you chose and why, with the dates: the engine is replaceable; the reasoning is the asset.
§FAQ/Common questions
Frequently asked
Is Postgres or MariaDB better for self-hosting?
Neither, on capability — both are mature, openly licensed and safe to run yourself. The decision is operational, and three axes actually diverge. Upgrades: a Postgres major is a cutover, because log shipping between servers running different major PostgreSQL release levels is generally not possible, so you run pg_upgrade or stand up logical replication; a MariaDB major is a binary swap plus mariadb-upgrade, because all MariaDB table data files are backward compatible. Replication: core Postgres has no synchronous multi-primary — its documentation says PostgreSQL does not offer this type of replication, though two-phase commit can be used to implement it in application code or middleware — while MariaDB ships Galera, which does, at the cost of an InnoDB-only schema with a primary key on every table. Support: PostgreSQL supports a major version for 5 years after its initial release, flat; MariaDB Community LTS binaries are released for 3 years after the GA date from 11.8 onward, with critical and security fixes continuing in source code releases for 2 additional years.
How long is MariaDB supported compared to PostgreSQL?
PostgreSQL gives every major version five years from its initial release, then a final minor release and end-of-life. MariaDB publishes two numbers rather than one: Community LTS binaries are released for 3 years after the GA date, and critical and security fixes are then provided in source code releases for 2 additional years. So total code maintenance is still five years — MariaDB is not unsupported at year three — but free prebuilt binaries from mariadb.org stop there, and from year four you are compiling your own, using a distribution's packages, or buying enterprise binaries. The change is recent and has a visible seam: for releases up to MariaDB 11.4 the binaries were released for 5 years, so 11.4 (GA 29 May 2024) carries a community end-of-maintenance date of 29 May 2029 while its successor 11.8 (GA 4 June 2025) carries 4 June 2028 — the older LTS outlasts the newer one by just under a year.
Does PostgreSQL support multi-master replication?
Not synchronously, in core — and the precise wording matters because both of the usual summaries are wrong. Under the heading Synchronous Multimaster Replication, PostgreSQL's documentation says PostgreSQL does not offer this type of replication, though PostgreSQL two-phase commit (PREPARE TRANSACTION and COMMIT PREPARED) can be used to implement this in application code or middleware. But the same chapter's Table 26.1 marks Allows multiple primary servers for built-in logical replication, which also has per-table granularity and is explicitly not marked as needing no conflict resolution. So there is a per-table multi-writer path: a server publishing its own changes can also subscribe to changes from another server, allowing data to flow in multiple directions. It applies a fixed conflict policy you cannot configure — an error raised until manually resolved on the NOT DEFERRABLE unique-constraint cases (insert_exists, update_exists, multiple_unique_conflicts), always applied regardless of the origin of the local row on update_origin_differs and delete_origin_differs, and simply skipped with no error on update_missing and delete_missing.
What does Galera require that ordinary MariaDB does not?
Two separate bills, and only the first one closes. The finite bill is schema, audited before you commit rather than discovered afterwards: MariaDB's known-limitations page states that replication works only with the InnoDB storage engine, with MyISAM support experimental behind wsrep_replicate_myisam. All tables should have a primary key, DELETE operations are unsupported on tables without one, and rows in such tables may appear in a different order on different nodes. Unsupported explicit locking includes LOCK TABLES and FLUSH TABLES with an explicit table list WITH READ LOCK, though the global FLUSH TABLES WITH READ LOCK is supported. The general query log and the slow query log cannot be directed to a table, so log_output=FILE is required. A write set is processed as a single memory-resident buffer, so wsrep_max_ws_rows and wsrep_max_ws_size bound a transaction; read those defaults off the server rather than the limitations page, which still says 128K rows and 2Gb and is behind it. On current MariaDB, the 11.4, 11.8 and 12.3 branches included, wsrep_max_ws_rows defaults to 0 — the variables page says the default was changed to 0, which essentially allows write sets to be any size — and wsrep_max_ws_size defaults to 2147483647, which that page calls 2GB and which is also the top of its documented range. The size cap is the one a bulk load meets. The permanent bill is on the same page, under Consistency and Isolation Anomalies, and it never closes: Galera runs a distributed variant of Snapshot Isolation that is subject to transactional phenomena even in healthy clusters without hardware or network faults. It does not implement a first-committer-wins policy for all operation types, so standard read-modify-write patterns common in many ORMs are unsafe without explicit locking such as SELECT ... FOR UPDATE; and a transaction that commits on one node may not be visible to a transaction starting on another, so users requiring real-time visibility must use the wsrep_sync_wait session variable. Budget for both: the schema audit is one-off, the locking discipline and the session variable are forever. One packaging note on top: upgrading a Galera cluster to MariaDB 12.3 or later requires manually installing the mariadb-server-galera package, because the automatic dependency was removed and a standard upgrade may leave the cluster unable to bootstrap.
Can I migrate from MariaDB to PostgreSQL, and how hard is it?
There is an actively developed open-source path, though not a settled one. pgloader handles both the schema and data parts of the migration in a single unmanned command, and its own site names MySQL and MariaDB as its flagship path, covering enum-to-type translation, TINYINT(1)-to-boolean and zero-date handling. Its documented limitations are where the hand-work sits: views are not migrated, triggers are not migrated, and of the geometric datatypes only POINT has been covered. Scope those three against your own schema before you start. Check the release state too — the current stable release is 3.6.9 from October 2022, and the only newer artefact is a prerelease tagged v4-dev, a Clojure rewrite built automatically from main and requiring Java 21 or later. The reverse direction, Postgres to MariaDB, is the thin one: a search in October 2026 surfaced no maintained open-source engine converter of pgloader's class pointing that way. Check that for yourself rather than taking it as settled, and price commercial migrators separately — but record the asymmetry as an input to the decision rather than discovering it later.
Further reading
- PostgreSQL on Kubernetes with CloudNativePG
- Postgres vs SQL Server: Standard, Enterprise, or Neither
- Postgres vs MongoDB: You May Already Have a Document Store
- Database Migration Tools: Rollback, Review and Evidence
- On-Prem RAG: Qdrant vs pgvector for Sovereign Retrieval
- IT Disaster Recovery Testing Without a Maintenance Window
- Kubernetes Secrets Are Still Broken: ESO Over Vault
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.