background-shape
YugabyteDB for Global Apps, A Hands On Tutorial
June 25, 2025 · 9 min read · by Muhammad Amal programming

TL;DR — YugabyteDB 2.23 gives you a Postgres-compatible SQL layer over a distributed storage engine that’s genuinely good for multi-region apps. Master tablespace placement and geo-partitioning before you put it under load, or you’ll pay cross-region latency on every request.

YugabyteDB is the other serious option in the Postgres-compatible distributed SQL space alongside CockroachDB. Architecturally it’s quite different. YugabyteDB layers a Postgres 15-compatible SQL engine on top of a separate distributed storage engine called DocDB. CockroachDB built its own SQL engine. The practical implications matter, and I’ll go through them.

I’ve used YugabyteDB on a couple of multi-region projects over the past year. The 2.23 release (April 2025) is a meaningful step forward, with better connection pooling, improved follower reads, and tighter Postgres compatibility. It’s the version I’d put in production today.

This guide is for engineers evaluating or starting with YugabyteDB. I’ll show how to set up a cluster, design schemas for global apps, and operate it without falling into the obvious traps. If you’ve read my CockroachDB tutorial the model will feel familiar but the details differ.

1. The Architecture

YugabyteDB has two main components:

  • YB-Master: cluster metadata, schema management, replica placement decisions. Like an etcd-plus-coordinator.
  • YB-TServer: actual storage and SQL processing. Each TServer holds tablets (shards), runs the Postgres-compatible SQL engine (YSQL), and serves client requests.
        +---------------+    +---------------+    +---------------+
        |  YB-Master 1  |    |  YB-Master 2  |    |  YB-Master 3  |
        |  (metadata,   |    |               |    |               |
        |   leader)     |    |               |    |               |
        +-------+-------+    +-------+-------+    +-------+-------+
                |                    |                    |
                +--------- Raft -----+---------+----------+
                |                    |         |
                v                    v         v
        +-------+-------+    +-------+-------+    +-------+-------+
        | YB-TServer 1  |    | YB-TServer 2  |    | YB-TServer 3  |
        | YSQL :5433    |    | YSQL :5433    |    | YSQL :5433    |
        | Tablets:      |    | Tablets:      |    | Tablets:      |
        |  t1, t4, t7   |    |  t2, t5, t8   |    |  t3, t6, t9   |
        +---------------+    +---------------+    +---------------+

The YSQL layer on each TServer is literally a fork of Postgres 15 with the storage layer replaced. This means most Postgres SQL works directly, including stored procedures, JSON functions, and extensions like pgcrypto.

Each table is split into tablets. Each tablet has three replicas spread across TServers. Tablets use Raft for consensus. Cross-tablet transactions use a two-phase commit protocol.

2. Cluster Setup

A minimal production cluster has three TServers and three Masters. They can be co-located.

Install on each node

curl -O https://downloads.yugabyte.com/releases/2.23.0.0/yugabyte-2.23.0.0-b710-linux-x86_64.tar.gz
tar xzf yugabyte-2.23.0.0-b710-linux-x86_64.tar.gz
mv yugabyte-2.23.0.0 /opt/yugabyte

Start the Masters

On each of the three master nodes:

/opt/yugabyte/bin/yb-master \
    --fs_data_dirs=/data/yb-master \
    --master_addresses=10.0.0.1:7100,10.0.0.2:7100,10.0.0.3:7100 \
    --rpc_bind_addresses=10.0.0.1:7100 \
    --webserver_port=7000 \
    --placement_cloud=aws \
    --placement_region=us-east-1 \
    --placement_zone=us-east-1a \
    >/var/log/yb-master.out 2>&1 &

The placement_* flags tell YugabyteDB about the physical topology. Get these right; the cluster makes replica placement decisions based on them.

Start the TServers

/opt/yugabyte/bin/yb-tserver \
    --fs_data_dirs=/data/yb-tserver \
    --tserver_master_addrs=10.0.0.1:7100,10.0.0.2:7100,10.0.0.3:7100 \
    --rpc_bind_addresses=10.0.0.11:9100 \
    --pgsql_proxy_bind_address=10.0.0.11:5433 \
    --webserver_port=9000 \
    --placement_cloud=aws \
    --placement_region=us-east-1 \
    --placement_zone=us-east-1a \
    >/var/log/yb-tserver.out 2>&1 &

After everything is up, configure the placement policy via the Master:

/opt/yugabyte/bin/yb-admin \
    --master_addresses=10.0.0.1:7100,10.0.0.2:7100,10.0.0.3:7100 \
    modify_placement_info aws.us-east-1.us-east-1a,aws.us-east-1.us-east-1b,aws.us-east-1.us-east-1c 3

This tells the cluster: spread replicas across three zones with replication factor 3.

Connect

YSQL listens on port 5433 (not 5432, deliberately to avoid collisions). The standard psql works:

psql -h 10.0.0.11 -p 5433 -U yugabyte -d yugabyte

3. Schema Design For Distributed SQL

The patterns are similar to CockroachDB but with YugabyteDB-specific twists.

Hash vs range sharding

YugabyteDB lets you choose how a table is sharded. The default for primary keys is hash sharding, which distributes writes evenly. You can opt into range sharding for tables where you want ordered storage:

-- Hash sharded (default): writes distribute, range scans slower
CREATE TABLE events (
    id uuid PRIMARY KEY,
    event_type text,
    payload jsonb
);

-- Range sharded: writes might cluster, range scans fast
CREATE TABLE events_by_time (
    ts timestamptz,
    event_id uuid,
    PRIMARY KEY (ts ASC, event_id)
);

Range sharded by timestamp is hot for inserts (always writing to the latest tablet) but fast for time-range queries. Pick consciously based on your access pattern.

Tablespace placement

This is YugabyteDB’s killer feature for global apps. You can define tablespaces that pin tables (or partitions) to specific regions:

CREATE TABLESPACE us_east WITH (replica_placement = '{
    "num_replicas": 3,
    "placement_blocks": [
        {"cloud": "aws", "region": "us-east-1", "zone": "us-east-1a", "min_num_replicas": 1},
        {"cloud": "aws", "region": "us-east-1", "zone": "us-east-1b", "min_num_replicas": 1},
        {"cloud": "aws", "region": "us-east-1", "zone": "us-east-1c", "min_num_replicas": 1}
    ]
}');

CREATE TABLESPACE eu_west WITH (replica_placement = '{
    "num_replicas": 3,
    "placement_blocks": [
        {"cloud": "aws", "region": "eu-west-1", "zone": "eu-west-1a", "min_num_replicas": 1},
        {"cloud": "aws", "region": "eu-west-1", "zone": "eu-west-1b", "min_num_replicas": 1},
        {"cloud": "aws", "region": "eu-west-1", "zone": "eu-west-1c", "min_num_replicas": 1}
    ]
}');

Then create tables in the relevant tablespace:

CREATE TABLE us_users (
    id uuid PRIMARY KEY,
    email text,
    home_region text
) TABLESPACE us_east;

CREATE TABLE eu_users (
    id uuid PRIMARY KEY,
    email text,
    home_region text
) TABLESPACE eu_west;

Now us_users queries run entirely in the us-east-1 region. No cross-region traffic. This is the foundation of low-latency global apps on YugabyteDB.

Geo-partitioning

The more elegant pattern is to partition a single logical table by region:

CREATE TABLE users (
    id uuid NOT NULL,
    email text,
    home_region text NOT NULL,
    PRIMARY KEY (id, home_region)
) PARTITION BY LIST (home_region);

CREATE TABLE users_us PARTITION OF users
    FOR VALUES IN ('us-east', 'us-west')
    TABLESPACE us_east;

CREATE TABLE users_eu PARTITION OF users
    FOR VALUES IN ('eu-west', 'eu-central')
    TABLESPACE eu_west;

A query like SELECT * FROM users WHERE id = ? AND home_region = 'us-east' runs entirely in us-east. The Postgres partition pruning logic combined with YugabyteDB’s tablespace placement means data stays local.

4. Transactions And Isolation

YugabyteDB defaults to snapshot isolation, not serializable. This is the opposite of CockroachDB. You can opt into serializable per session:

BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;

Snapshot isolation has well-known anomalies (write skew) but is faster than serializable. For most application code that’s not handling money or critical inventory, snapshot is fine. For anything sensitive, use serializable.

Distributed transactions

Cross-tablet transactions use 2PC. They work but they’re slower than single-tablet transactions, especially across regions. Where possible, design your transactions to fit within a single tablet (i.e., use the same hash key for all writes in a transaction).

Follower reads

Like CockroachDB, YugabyteDB supports follower reads for stale-but-fast queries:

SET yb_read_from_followers = true;
SET default_transaction_read_only = true;
-- now SELECTs in this session can use followers

The default staleness is 30 seconds, configurable via yb_follower_read_staleness_ms. For dashboard-type queries this is great. For anything user-facing where staleness might confuse the user, stick with leader reads.

A common production pattern is two connection pools: one configured for follower reads (used by report generators, dashboards, and analytics), and one for leader reads (used by the transactional API). Latency on the follower-read pool is much lower because reads can be served from any replica, including the geographically closest one to the application server.

5. Operations And Monitoring

Connection pooling

YugabyteDB connection handling is heavier than Postgres because each connection allocates resources on the TServer. Use a pooler. PgBouncer 1.24 works fine in transaction mode:

[databases]
yugadb = host=tserver1.example.com port=5433 dbname=yugadb

[pgbouncer]
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 30

YugabyteDB 2.23 added native connection pooling at the TServer level. You can enable it with --yb_enable_read_committed_isolation=true and a few related flags. For most setups, PgBouncer is still simpler.

Backup and restore

# Snapshot a database
/opt/yugabyte/bin/yb-admin \
    --master_addresses=10.0.0.1:7100,10.0.0.2:7100,10.0.0.3:7100 \
    create_database_snapshot ysql.yugadb

# Export to object storage
/opt/yugabyte/bin/yb-admin \
    --master_addresses=10.0.0.1:7100,10.0.0.2:7100,10.0.0.3:7100 \
    create_snapshot_schedule 1 1440 ysql.yugadb

Snapshot schedules create daily snapshots retained for 1440 minutes (one day). You can also configure incremental backups to S3 through Yugabyte Platform (their managed offering).

For point-in-time recovery, YugabyteDB 2.23 supports PITR within a snapshot schedule’s retention window:

/opt/yugabyte/bin/yb-admin \
    --master_addresses=... \
    restore_snapshot_schedule <schedule_id> <restore_time>

Monitoring

YugabyteDB exposes Prometheus metrics on each TServer’s webserver port (9000 by default):

curl http://10.0.0.11:9000/prometheus-metrics

Key metrics:

  • handler_latency_yb_tserver_TabletServerService_Read — read latency.
  • handler_latency_yb_tserver_TabletServerService_Write — write latency.
  • rocksdb_read_block_compaction_micros — compaction overhead in the storage engine.

The Postgres-side metrics (pg_stat_statements, pg_stat_activity) all work since YSQL is Postgres underneath. You can use the same queries you’d use on regular Postgres.

6. Query Patterns That Work

Single-tablet queries

The fastest queries hit a single tablet. With hash-sharded primary keys, that’s any query filtering by the full primary key:

SELECT * FROM users WHERE id = 'specific-uuid';

This is a single-tablet lookup, sub-millisecond latency typically.

Index lookups

YugabyteDB supports secondary indexes, which are themselves distributed:

CREATE INDEX ON users (email);
SELECT * FROM users WHERE email = 'foo@example.com';

The index lookup goes to one tablet, then a follow-up read to the data tablet (which may or may not be the same). Two-hop queries are slightly slower than single-hop but still very fast.

Multi-region pattern

-- Insert a user (routed to home region's tablet)
INSERT INTO users (id, email, home_region)
VALUES (gen_random_uuid(), 'foo@example.com', 'us-east');

-- Lookup (stays local)
SELECT * FROM users WHERE id = ? AND home_region = 'us-east';

-- Cross-region: only when actually needed
SELECT * FROM users WHERE email = 'foo@example.com';  -- queries multiple partitions

The whole design philosophy is to keep queries within a region whenever possible. Cross-region queries work but should be rare.

Common Pitfalls

1. Sequential primary keys

Just like CockroachDB, sequential primary keys create hot tablets. Use UUIDs or hash-based keys. Use gen_random_uuid() for new tables.

2. Ignoring tablespace placement

If you don’t configure tablespaces, all your tables spread their replicas across all available zones. This is fine for single-region but disastrous for multi-region (writes need cross-region quorum). Always set up tablespaces for multi-region clusters.

3. Mixing snapshot and serializable

The default isolation is snapshot, but if your code base assumes Postgres-default read-committed semantics, you might see unexpected behavior. Be explicit about isolation level in critical paths.

4. Underestimating compaction cost

YugabyteDB uses RocksDB under the hood, which does LSM-tree compaction. Heavy write workloads can hit compaction bottlenecks. Monitor compaction metrics and consider tuning rocksdb_compaction_size_threshold if you’re write-heavy.

Troubleshooting

Query is slow, no obvious reason

Check whether it’s hitting multiple tablets:

EXPLAIN (ANALYZE, DIST) SELECT ...;

The DIST option (YugabyteDB-specific) shows distribution details. You want to see one tablet or as few as possible for hot-path queries.

Tablet leader on the wrong region

/opt/yugabyte/bin/yb-admin \
    --master_addresses=... \
    list_leader_counts

If leaders aren’t where you expect, force a rebalance:

/opt/yugabyte/bin/yb-admin \
    --master_addresses=... \
    set_load_balancer_enabled 1

High write latency in multi-region

Cross-region Raft quorum is the most likely cause. Each write needs acks from 2 of 3 replicas, and if those replicas are in different regions, you pay wide-area latency. Either use tablespaces to confine replicas to one region (with backup/DR through async replication to another region) or accept the latency.

For the Postgres compatibility layer details, the Postgres compatibility documentation covers the SQL features that YugabyteDB inherits from Postgres 15.

Wrapping Up

YugabyteDB 2.23 is a serious option for globally distributed apps where you want Postgres SQL and can tolerate the operational complexity of a distributed database. Its tablespace-based geo-partitioning is genuinely elegant. The 2.23 improvements to connection pooling and follower reads make it more usable for typical web apps than earlier versions.

Compared to CockroachDB, YugabyteDB has tighter Postgres compatibility (because it literally embeds a Postgres SQL engine) but a more complex operational model. Choose based on what you value more. If you want to compare directly, the CockroachDB 24.3 tutorial covers the other side.