JusDB
Free DB Audit
Database Architecture

YugabyteDB for PostgreSQL Teams: Compatibility Gaps and Migration

What changes when a PostgreSQL team moves to YugabyteDB YSQL: unsupported features, isolation and retries, sequences, primary key sharding, colocation, and a Voyager migration runbook.

JusDB Team
Published November 6, 2026
19 min read

YugabyteDB's YSQL API reuses the PostgreSQL query layer, so psql, your driver, and most of your schema work on day one. The storage underneath is a distributed, Raft-replicated key-value store, and that is where PostgreSQL habits break. A transaction that never failed on PostgreSQL returns ERROR: could not serialize access due to concurrent update (SQLSTATE 40001). A bigserial key hands out values in blocks of 100 per connection. A schema with a GiST index, an exclusion constraint, or an UNLOGGED table will not import. As of November 2026, the current releases are YugabyteDB v2026.1 (STS) and v2025.2 (LTS), and both base YSQL on PostgreSQL 15. This guide covers the gaps to test, how clients should retry, how to design keys, and how YugabyteDB Voyager moves the data.

TL;DR
  • YSQL is based on PostgreSQL 15 from v2025.1 onward. v2024.2 LTS and earlier are PostgreSQL 11 based and need a separate YSQL major upgrade to reach 15.
  • Exclusion constraints, GiST indexes, UNLOGGED tables, XA, and MERGE are not supported. LISTEN/NOTIFY is Early Access and off by default.
  • Read Committed behaves like PostgreSQL only when yb_enable_read_committed_isolation=true. Otherwise it runs as Snapshot, and clients must retry 40001 and 40P01.
  • A plain PRIMARY KEY (id) is hash-sharded on id. Range keys on monotonic values send every insert to one tablet.
  • Voyager v2026.10.2 assesses, exports, and imports PostgreSQL 11 to 18, with live migration and optional fall-forward or fall-back.

Scope and review date: November 2026. This post covers YSQL on YugabyteDB v2026.1 (latest patch v2026.1.3.0) and v2025.2 LTS, with PostgreSQL sources moved by YugabyteDB Voyager v2026.10.2. Versions, flags, defaults, and commands were checked against the YugabyteDB documentation and release notes on 9 November 2026. They were not run in a lab, so rehearse every step on a staging copy.


Which YugabyteDB release should a PostgreSQL team target?

The YugabyteDB releases page lists two stable tracks: standard-term support (STS) and long-term support (LTS). The PostgreSQL 15 features page says that from v2025.1 (stable) and v2.25 (preview), YugabyteDB is based on PostgreSQL 15. YSQL was originally based on PostgreSQL 11.2.

SeriesTypeFirst releasePostgreSQL baseEnd of maintenance
v2026.1STS29 June 20261529 June 2027
v2025.2LTS11 December 20251511 December 2027
v2025.1STS23 July 20251523 July 2026 (passed)
v2024.2LTS9 December 2024119 December 2026

A new project should start on v2025.2 LTS or v2026.1 STS. As of November 2026, the newest patches are v2026.1.3.0 (5 November 2026) and v2025.2.8.1 (4 November 2026). The v2026.1 release notes add one upgrade trap: a cluster on v2025.2.6.0 cannot upgrade to v2026.1.0.x or v2026.1.1.x, so go straight to v2026.1.2.0 or later.

sql
-- On the YugabyteDB cluster
SELECT version();

Interpretation: the string starts with the PostgreSQL base and carries a -YB- build suffix, for example PostgreSQL 15.2-YB-2.25.0.0-b0. If it starts with PostgreSQL 11.2, the cluster is on v2024.2 or earlier, and moving it to a PostgreSQL 15 based release goes through the YSQL major upgrade procedure, not a normal rolling upgrade.

Which PostgreSQL features does YSQL not support?

The PostgreSQL compatibility page for v2026.1 lists these as not supported, each with a GitHub issue:

  • exclusion constraints, constraint triggers, and the REFERENCING clause for triggers;
  • deferrable constraints (#1709); the SQL feature table separately lists deferrable foreign keys, so check which kind you use;
  • GiST indexes, multi-column GIN indexes, and CREATE ACCESS METHOD;
  • indexes on complex types such as INET, CITEXT, JSONB, and arrays;
  • UNLOGGED tables, XA syntax, XML functions, CREATE CONVERSION, and some ALTER TABLE and ALTER TYPE variants.

Two PostgreSQL 15 features are not yet implemented: MERGE and nondeterministic collations. LISTEN/NOTIFY is Early Access from v2025.2.3.0 and needs ysql_yb_enable_listen_notify=true, which defaults to false.

sql
-- Run on the PostgreSQL source before you plan anything
SELECT conrelid::regclass AS table_name, conname, contype, condeferrable
FROM pg_constraint
WHERE contype = 'x' OR condeferrable;

SELECT c.oid::regclass AS index_name, am.amname
FROM pg_class c JOIN pg_am am ON am.oid = c.relam
WHERE c.relkind = 'i' AND am.amname IN ('gist', 'spgist');

SELECT oid::regclass AS unlogged_table
FROM pg_class WHERE relkind = 'r' AND relpersistence = 'u';

SELECT tgrelid::regclass, tgname
FROM pg_trigger WHERE tgconstraint <> 0 AND NOT tgisinternal;

Interpretation: every row is a schema change to plan. The last query finds user-defined constraint triggers; the filter skips the internal ones that foreign keys create. Voyager's assessment finds the same items, but these queries give you a count in a minute.

Also search pg_stat_statements for MERGE, LISTEN, and pg_notify. Rewrite MERGE as INSERT ... ON CONFLICT, and move notifications to a queue unless you accept an Early Access feature.

PostgreSQL 15 and YugabyteDB 2026.1 YSQL side by side

Switch tabs to see the behaviour your application and schema will meet

PostgreSQL 15

AreaPostgreSQL 15
READ COMMITTEDNative Read Committed
Write conflicts at the default levelStatement waits, then sees the new row version
Sequence cacheDefault CACHE 1
PRIMARY KEY (id)B-tree on one server
LISTEN/NOTIFYSupported
MERGE, GiST, exclusion constraintsSupported

YugabyteDB 2026.1 YSQL

AreaYugabyteDB 2026.1 YSQL
READ COMMITTEDRead Committed only if yb_enable_read_committed_isolation=true; otherwise Snapshot
Write conflicts at the default levelRead Committed: whole statement retried in the server; Snapshot: 40001 to the client
Sequence cacheDefault CACHE 100, held per connection
PRIMARY KEY (id)Hash-sharded on id across tablets
LISTEN/NOTIFYEarly Access, off by default
MERGE, GiST, exclusion constraintsNot supported

Same SQL, different engine: these differences show up only under concurrency, at scale, or at import time, so test with production-like traffic.

Which extensions are available?

The extensions page lists these as pre-bundled, enabled with CREATE EXTENSION: pg_stat_statements, pgcrypto, uuid-ossp, pg_trgm, hstore, postgres_fdw, auto_explain, pgAudit, pg_partman, pg_cron, pgvector, pg_hint_plan, and Orafce. PostGIS, TimescaleDB, Citus, and pg_repack are not listed, and only supported extensions can be installed.

sql
-- On the source
SELECT extname, extversion FROM pg_extension ORDER BY 1;
-- On the YugabyteDB target
SELECT name, default_version FROM pg_available_extensions ORDER BY 1;

Interpretation: any source extension missing from the target list blocks the migration until you replace it.

How do isolation levels and retries differ from PostgreSQL?

The isolation levels page maps PostgreSQL's names onto Serializable, Snapshot (REPEATABLE READ), and Read Committed. Read Committed works as in PostgreSQL only when the YB-TServer flag yb_enable_read_committed_isolation is true, and the flag reference lists its default as false. New v2025.2 or later universes deployed with yugabyted, YugabyteDB Anywhere, or Aeon have it enabled. Manually deployed universes and older versions do not, so their default level is effectively Snapshot.

bash
# The YB-TServer web UI lists the running flags
curl -s 'http://tserver-1:9000/varz?raw' | grep yb_enable_read_committed_isolation

Interpretation: check every TServer. =false means your application runs at Snapshot isolation and must handle serialization errors it never saw on PostgreSQL.

With Read Committed on, YugabyteDB retries the whole conflicting statement on a newer snapshot, up to yb_max_query_layer_retries, instead of re-checking only the conflicting rows as PostgreSQL does. The Read Committed page lists where that internal retry stops working: a response larger than ysql_output_buffer_size, several semicolon-separated statements sent through the simple query protocol, and statements after the first in a driver batch.

Under Snapshot and Serializable, the transaction retry guide names two errors the client should retry: 40001 (could not serialize access due to concurrent update) and 40P01 (deadlock detected).

python
import time
from psycopg2 import errors

RETRYABLE = (errors.SerializationFailure, errors.DeadlockDetected)

def transfer(conn, src, dst, amount, max_attempts=10):
    delay = 0.002
    for attempt in range(1, max_attempts + 1):
        try:
            with conn.cursor() as cur:
                cur.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (amount, src))
                cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, dst))
            conn.commit()
            return
        except RETRYABLE:
            conn.rollback()
            if attempt == max_attempts:
                raise
            time.sleep(delay)
            delay *= 2

Interpretation: this is the documented pattern: roll back, back off exponentially, cap the attempts, and retry only the two retriable SQLSTATEs. A dropped connection during COMMIT is not retried, because you cannot tell whether the commit landed.

Why do sequences behave differently, and how big should the cache be?

On PostgreSQL, a sequence defaults to CACHE 1, and each nextval() is a cheap local call. On YugabyteDB, sequence state lives in a replicated system table, so every refill costs a distributed write. The CREATE SEQUENCE reference sets the default CACHE to 100 and keeps cached values in the local node's memory. The flag ysql_sequence_cache_minval (default 100) is a floor: the effective cache is the larger of the flag and the CACHE clause.

By default (ysql_sequence_cache_method=connection), each connection grabs its own block. Two sessions inserting at the same time get values such as 1 and 101, and values left in a closed connection's block are never used. With ysql_sequence_cache_method=server, a block is shared by all connections on one TServer. The nextval page notes that setval and restarting a sequence do not work with server caching.

sql
-- Explicit sequence with a large cache, instead of SERIAL
CREATE SEQUENCE orders_id_seq CACHE 1000;
CREATE TABLE orders (
  id         bigint NOT NULL DEFAULT nextval('orders_id_seq'),
  customer   bigint NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (id HASH)
);
ALTER SEQUENCE orders_id_seq OWNED BY orders.id;

Interpretation: the CREATE SEQUENCE page says SERIAL creates a sequence with a cache of 1 and recommends this pattern instead. A larger cache means fewer distributed writes and larger gaps. Code that assumes IDs are gap-free or ordered by insert time breaks either way; if you need neither, gen_random_uuid() is built in.

How should you design primary keys to avoid hotspots?

The CREATE TABLE reference says that by default only the first primary key column is the hash column, so PRIMARY KEY (k1, k2) means k1 HASH, k2 ASC. Hashing spreads sequential IDs evenly, so an insert-heavy table on id HASH has no single hot tablet. The cost is that range scans on that column touch every tablet.

Range sharding (ASC or DESC) keeps rows in key order, which suits range queries. The data sharding page warns that when inserted data is already sorted, all new inserts go to only one tablet. A timestamp or sequence as the leading range column is the classic hotspot.

sql
-- Hotspot: every new row lands in the last tablet
CREATE TABLE events_bad (
  created_at timestamptz,
  id         bigint,
  payload    jsonb,
  PRIMARY KEY (created_at ASC, id ASC)
);

-- Spread writes by tenant, keep time order inside each tenant
CREATE TABLE events (
  tenant_id  bigint,
  created_at timestamptz,
  id         bigint,
  payload    jsonb,
  PRIMARY KEY ((tenant_id) HASH, created_at DESC, id)
) SPLIT INTO 12 TABLETS;

Interpretation: in the second table, writes for different tenants hash to different tablets, and a query such as "latest 50 events for tenant 42" reads one contiguous range. SPLIT INTO applies to hash-sharded tables. Range-sharded tables are pre-split with SPLIT AT VALUES. Automatic tablet splitting (default on) handles later growth.

Watch the hash default flag

yb_use_hash_splitting_by_default defaults to true. Enhanced PostgreSQL Compatibility Mode sets it to false to match PostgreSQL's ascending indexes. With it off, a plain PRIMARY KEY (id) on a bigserial becomes range-sharded, and inserts pile onto one tablet. Write HASH or ASC explicitly in every key.

Tables without a primary key get a hidden ybrowid hash key, and Voyager's live migration does not support them, so add keys on the source first.

When should tables be colocated?

Hundreds of small tables cost memory, files, and network hops per tablet. Colocation puts many tables in one replicated tablet. The docs suggest it for small datasets that need high availability or geo-distribution (they cite databases typically under 50 GB), for large databases with many small tables, and for many small per-tenant databases.

sql
CREATE DATABASE app WITH COLOCATION = true;
\c app
-- Small tables are colocated by default in this database
CREATE TABLE countries (code text PRIMARY KEY, name text NOT NULL);
-- Opt the large, write-heavy table out so it shards normally
CREATE TABLE orders (id bigint, customer bigint, PRIMARY KEY (id HASH))
  WITH (COLOCATION = false);

Interpretation: colocated tables share one tablet, so joins between them stay local. Tablet splitting is disabled for colocated tables, and size metrics are reported for the shared tablet, not per table. Keep high-load tables out, or the shared tablet becomes the hotspot.

What does a multi-region cluster cost in write latency?

Each write commits through Raft consensus, so it waits for a majority of replicas. In one region, that is a few milliseconds of network. Across regions, the inter-region round trip is part of every commit. The global database example shows reads of about 2 ms from local leaders and writes of about 30 ms, because the nearest other replica is about 30 ms away. It also shows that moving the regions closer cuts the example to about 10 ms.

bash
# Pin tablet leaders to the region where the application runs
./bin/yb-admin --master_addresses 10.0.1.10:7100,10.0.2.10:7100,10.0.3.10:7100 \
  set_preferred_zones aws.us-east-2.*:1 aws.us-central-1.*:2 aws.us-west-1.*:3

Interpretation: leaders move to the rank 1 region, so local reads stay fast, but writes still wait for a replica in another region. The same example recommends replication factor 5 with two replicas in each of the two preferred regions, so a failed leader is replaced by a local follower.

For reports that accept stale data, follower reads (yb_read_from_followers in a read-only transaction) serve reads from the nearest replica, up to 30 seconds old by default. Writes get no such shortcut, so a chatty ORM pays the inter-region round trip on every statement it waits on.

How does a YugabyteDB Voyager migration work?

YugabyteDB Voyager is the migration tool. As of November 2026, the latest release is v2026.10.2 (27 October 2026). It supports PostgreSQL 11.x to 18.x sources, including Amazon RDS, Aurora, Cloud SQL, and Azure Database for PostgreSQL. It runs offline or live migration, and live migration can add fall-forward (to a source replica) or fall-back (to the source). Live migration is PostgreSQL only.

A live migration assesses, moves the schema, streams a snapshot and then changes, cuts over, and finalizes. The live migration guide lists the limits that matter most:

  • schema changes on the source are not applied to the target during the migration (schema detect-drift, Tech Preview, reports them);
  • tables without a primary key are not supported, and TRUNCATE on the source is not replicated;
  • types such as POINT, XML, TIMETZ, and VECTOR are not supported, and v2026.10.1 stops export data on tables that use INHERITS;
  • sequences not owned by an integer column must be resumed by hand at cutover.

Voyager live migration from PostgreSQL to YugabyteDB

The source keeps taking writes until cutover; the dashed path exists only if you prepare for fall-back

Voyager live migration flow Step 1: yb-voyager exports a snapshot of the PostgreSQL source and then streams its changes through a replication slot into the export directory. Step 2: yb-voyager imports the snapshot and then applies the change events to the YugabyteDB target. Step 3: after cutover, if fall-back was prepared, changes on YugabyteDB stream back to the PostgreSQL source so it can be used again; otherwise the source is retired. PostgreSQL source yb-voyager export directory YugabyteDB target (YSQL) 1 snapshot, then changes (CDC) 2 import data to target 3 optional fall-back: changes on YugabyteDB flow back to the source

Watch the slot: Voyager creates a replication slot on the source, and the docs warn it can use disk space rapidly. Cutover or end migration removes it; an abandoned migration leaves it behind.

Assess the source with yb-voyager

Start with assess-migration. Its report covers schema changes, unsupported features and query constructs, recommended sharding and colocation, cluster sizing, and a migration time estimate. Unsupported query detection needs pg_stat_statements on the source. If the Voyager host cannot reach the source, run yb-voyager-pg-gather-assessment-metadata.sh next to the database and pass the result with --assessment-metadata-dir. Without --source-db-password, Voyager prompts for the password or reads SOURCE_DB_PASSWORD, which keeps it out of shell history.

yb-voyager assess-migration against a PostgreSQL source

Illustrative output, shaped like Voyager's console summary; your issue count and paths will differ

voyager@migration-host:~
$ yb-voyager assess-migration --source-db-type postgresql \ --source-db-host pg-primary.internal --source-db-user ybvoyager \ --source-db-name app --source-db-schema public,billing \ --export-dir ~/migYugabyteDB Voyager: Migration AssessmentPreparing for migration assessment doneAssessing migration doneGenerating report done Assessment completed successfully Issues detected: 7 JSON report: ~/mig/assessment/reports/migration_assessment_report.json HTML report: ~/mig/assessment/reports/migration_assessment_report.html

Read the issues first: each issue names the object and a workaround. Fix them in the exported schema, then rerun analyze-schema until its report is clean.

What does the live migration runbook look like?

This is the PostgreSQL to YugabyteDB live path without fall-back, in the order the live migration guide gives. Every command also accepts --config-file with the live-migration.yaml template, which keeps credentials out of shell history.

Live migration runbook: PostgreSQL to YugabyteDB with Voyager

Eight steps; steps 6 and 7 are the write pause

  1. Prepare the source

    Set wal_level to logical and restart (on RDS, rds.logical_replication=1). The grant script creates permissions and sets replica identity FULL.

    psql -c "SHOW wal_level"
  2. Prepare the target

    For the migration only: set the retry flag below and turn off Read Committed. Create the database, colocated if the assessment says so.

    --ysql_pg_conf_csv=yb_max_query_layer_retries=0
  3. Assess

    Run the assessment and agree the schema changes before moving anything.

    yb-voyager assess-migration \ --config-file live-migration.yaml
  4. Move the schema

    Export, analyze, edit the files under the export directory, and import.

    yb-voyager export schema --config-file live-migration.yaml yb-voyager analyze-schema --config-file live-migration.yaml yb-voyager import schema --config-file live-migration.yaml
  5. Start the snapshot and change stream

    Run both in parallel; the export type is snapshot-and-changes. Watch progress with get data-migration-report.

    yb-voyager export data from source \ --config-file live-migration.yaml yb-voyager import data to target \ --config-file live-migration.yaml
  6. Stop writes and start the cutover

    Write pause starts

    Stop application writes, wait for the export rate to reach 0, then cut over.

    yb-voyager initiate cutover to target \ --export-dir ~/mig --prepare-for-fall-back false
  7. Wait for the cutover and repoint

    Write pause ends

    When the status reports completion, point the application at YugabyteDB and restart it. Resume any unowned sequences now.

    yb-voyager cutover status --export-dir ~/mig
  8. Finalize and clean up

    Create NOT VALID constraints and refresh materialized views, revert the step 2 flags, then end the migration to drop the slot.

    yb-voyager finalize-schema-post-data-import \ --config-file live-migration.yaml --refresh-mviews true yb-voyager end migration --config-file live-migration.yaml

Rollback point: until step 6, PostgreSQL is still the primary, so you stop Voyager and drop the slot. After step 6 without fall-back, nothing flows back to the source. If you need a way back, run the fall-back variant with --prepare-for-fall-back true instead.

Before step 6, compare row counts on large tables and run the application's read paths against the target.

When is YugabyteDB a fit, and when is PostgreSQL HA enough?

YugabyteDB earns its trade-offs when the requirement is one of these: writes that outgrow a single primary, surviving the loss of a zone or region with automatic failover over synchronous replication, or keeping rows in a specific region for data residency. The 2023 CockroachDB, Spanner, and YugabyteDB comparison covers how it differs from the other distributed SQL engines.

Plain PostgreSQL with streaming replication and Patroni is usually enough when one primary handles the write load, a few seconds of failover is acceptable, and the application depends on extensions or features YSQL lacks. You keep single-node commit latency, every extension, and your current tooling. Our guides to a Patroni production cluster and Patroni with Consul cover that route, and Citus is the middle option when you need sharding but want to stay on PostgreSQL.

A useful test: if one well-sized primary handles your peak write rate with headroom and the data fits on one server, the problem is availability, not scale.

Frequently asked questions

Which PostgreSQL version is YugabyteDB YSQL compatible with?

From v2025.1 onward, YSQL is based on PostgreSQL 15. That includes v2025.2 LTS and v2026.1 STS, the current releases as of November 2026. v2024.2 LTS and earlier are based on PostgreSQL 11.2, and moving them to a PostgreSQL 15 based release requires the YSQL major upgrade procedure rather than a normal rolling upgrade.

Why does my application get serialization errors on YugabyteDB but not on PostgreSQL?

Usually because Read Committed is not enabled. When the TServer flag yb_enable_read_committed_isolation is false, which is the flag's default and common on manually deployed clusters, READ COMMITTED runs as Snapshot isolation. Conflicts then return SQLSTATE 40001 to the client. Enable the flag, or add retry logic with rollback and exponential backoff for 40001 and 40P01.

Does YugabyteDB support PostgreSQL extensions like PostGIS or TimescaleDB?

They are not on the supported list as of November 2026. The extensions page lists pre-bundled extensions such as pg_stat_statements, pgcrypto, pg_trgm, hstore, postgres_fdw, pg_partman, pg_cron, pgvector, pg_hint_plan, and pgAudit. PostGIS, TimescaleDB, and Citus are not listed, and only supported extensions can be installed. Check pg_available_extensions on your target version.

Can YugabyteDB Voyager migrate PostgreSQL with minimal downtime?

Yes. Voyager's live migration copies a snapshot and then streams changes from a PostgreSQL 11 to 18 source, so writes pause only during cutover. It supports optional fall-forward to a source replica or fall-back to the source. Tables need primary keys, and source schema changes during the migration are not applied to the target.

Key takeaways

  • Target v2025.2 LTS or v2026.1 STS, both PostgreSQL 15 based; v2024.2 and earlier are PostgreSQL 11 based.
  • Inventory exclusion constraints, GiST indexes, UNLOGGED tables, MERGE, LISTEN/NOTIFY, and extensions on the source before you commit to a date.
  • Confirm yb_enable_read_committed_isolation on every TServer, and add retries for 40001 and 40P01 wherever Snapshot or Serializable is used.
  • Write HASH or ASC in every primary key, never lead a range key with a timestamp or sequence, and size sequence caches knowing values will have gaps.
  • Rehearse the full Voyager live migration on staging, including cutover and end migration, and decide on fall-back before step 6.

Plan a YugabyteDB migration with JusDB

JusDB's YugabyteDB migration team can review your PostgreSQL schema and workload against YSQL's gaps, help you decide whether YugabyteDB or PostgreSQL high availability fits the requirement, and plan a Voyager migration with a rehearsed cutover and rollback path.

Bring your PostgreSQL version, the assessment report if you have one, your extension list, and your latency and failover targets. Your team approves every production change.

Share this article

Database engineering notes

Articles like this one, in your inbox. No spam, unsubscribe anytime.

JusDB Team

Official JusDB content team

Keep reading

Normalization vs Denormalization (2026): When Each Wins for Database Performance

Explore the trade-offs between database normalization and denormalization for optimal performance. Learn when to normalize for data integrity and when to denormalize for query speed.

6 minMay 13, 2026
Read

Database Schema Design for Scalability (2026): 12 Patterns DBAs Use in Production

Twelve schema design patterns that survive production scale: primary key strategy, normalization trade-offs, partitioning, sharding boundaries, and indexing rules.

10 minMay 9, 2026
Read

Solving Cache Invalidation and Stampede in Valkey with Debezium CDC

Eliminate stale cache reads and thundering-herd stampedes by streaming MySQL binlog changes to Valkey via Debezium CDC. Full Java setup with embedded engine and XFetch.

MySQL15 minMar 7, 2026
Read