The problem: PostgreSQL scales up, not out
PostgreSQL is a remarkable database. It is correct, extensible, and has a huge ecosystem. For most applications, a single well-tuned instance is the right answer for years.
But a single Postgres server has one writer, and that sets a ceiling. When you hit it, the usual escape routes each have a cost:
- Bigger hardware (vertical scaling). It works until the largest instance you can buy is still too small, and costs grow faster than capacity.
- Read replicas. They offload reads, but every write still goes through one primary. Replicas lag, so reads can be stale, and a write-heavy workload doesn't improve at all.
-
Citus + Patroni. Patroni on its own only protects availability: a standby sits idle waiting for the primary to die, so it adds no write capacity. Citus is the piece that scales writes. It turns Postgres into a sharded cluster, with a coordinator node routing queries to worker nodes that each hold a slice of your distributed tables. Pairing the two gets you scale and availability, but you assemble and operate the result yourself:
- Every node group needs its own HA setup, so you end up running a Patroni-managed primary and standby for the coordinator and for each worker. That is a lot of clusters to monitor, upgrade, and back up.
- The coordinator is a funnel that all queries pass through, so it has to be kept highly available too.
- Promotion inside each group is still a window of downtime, with a risk of losing recent transactions if replication is asynchronous.
- Your data model changes. You choose a distribution column, and joins and foreign keys across shards only work when tables are co-located on that key or are small reference tables replicated everywhere.
- Adding workers means triggering a shard rebalance.
- Manual sharding. Splitting data across several Postgres instances by tenant or key range does scale writes, but you inherit the hard parts yourself. Cross-shard queries and transactions, foreign keys across shards, schema migrations on every shard, rebalancing when one grows faster than the others, and application code that now has to know where data lives.
What teams actually want is Postgres semantics (SQL, transactions, foreign keys, the drivers and ORMs they already use) on a system that adds capacity by adding machines and survives node failures without a human in the loop.
How YugabyteDB approaches it
YugabyteDB is a distributed SQL database. Its core is open source under the Apache 2.0 license, so you can self-host it, inspect it, and run it anywhere without a license fee. The key design choice is to reuse PostgreSQL's query layer on top of a distributed storage engine. From the application's side it speaks the Postgres wire protocol, so standard drivers and tools connect to it.
The architecture has a few moving parts worth understanding:
- Tablets (shards). Each table is automatically split into tablets, which are chunks of rows assigned by hashing the primary key (or by key range, if you choose). Tablets are the unit of distribution. You never write shard-routing logic.
- Replication with Raft. Every tablet is replicated across nodes. The replication factor (RF) says how many copies exist; with RF=3, each tablet has three. For each tablet, the replicas run a Raft consensus group, with one leader serving reads and writes and followers staying in sync. If a leader's node dies, the followers elect a new leader in seconds, with no committed data lost.
-
Two server processes.
yb-masterstores cluster metadata (which tables exist, which tablets live where) and runs the background balancer.yb-tserverstores and serves the data. In a small cluster, each node runs both. - Automatic rebalancing. Add a node and the cluster moves tablets onto it in the background. Remove one and its tablets are re-replicated elsewhere.
- Distributed transactions. ACID transactions work across tablets and nodes, which is what makes foreign keys, joins, and multi-row updates behave the way Postgres developers expect.
Put together, that is horizontal scaling for writes (more nodes means more tablet leaders to spread load), high availability (Raft failover per tablet), and a familiar interface.
The honest trade-offs
A distributed database isn't free. Every write has to reach a majority of replicas, so write latency is higher than a single-node Postgres on the same hardware, especially across zones or regions. Postgres feature parity is very good but not total, so check the extensions and features you depend on. Resource needs are heavier, since each node runs a full storage engine and a Postgres layer. And the benefit only shows up when you need it. If one Postgres box comfortably handles your load, keep it.
Demo: a three-node cluster on DigitalOcean
We'll build a three-node YugabyteDB cluster on DigitalOcean droplets, load some data, look at how it's distributed, and then kill a node to watch the cluster keep serving.
1. Provision the droplets
Create three droplets in the same region and the same VPC, so the nodes can reach each other on private IPs.
- OS: Ubuntu LTS. Binaries are built against a specific glibc baseline, so check the supported operating systems list before choosing. Newer or unusual distros can crash during initialization.
- Size: 2 vCPU / 4 GB RAM each is comfortable. Smaller boxes can work for a throwaway demo, but memory pressure causes confusing slowness rather than clean errors.
- Note each droplet's private IP (for cluster traffic) and public IP (for your SSH access).
2. Firewall
The database ports shouldn't be reachable from the open internet. Apply a DigitalOcean Cloud Firewall (or ufw) that allows SSH from your IP and the following ports only within the VPC:
| Port | Purpose |
|---|---|
| 7100 |
yb-master RPC |
| 9100 |
yb-tserver RPC (replication traffic) |
| 7000 / 9000 | Master / TServer web UIs |
| 15433 |
yugabyted UI |
| 5433 | YSQL (the Postgres-compatible endpoint) |
If you run ufw, allow SSH before enabling it so you don't lock yourself out.
3. Prepare each node
Run on every node. Raise file limits (each tablet uses files) and set Transparent Huge Pages to madvise:
cat <<'EOF' | sudo tee -a /etc/security/limits.conf
* soft nofile 1048576
* hard nofile 1048576
* soft nproc 12000
* hard nproc 12000
EOF
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
sudo apt update && sudo apt install -y python-is-python3 wget
Log out and back in so the limits apply. The THP setting resets on reboot, so persist it (for example via a systemd unit) before treating the cluster as anything more than a demo.
4. Download and install
Pick a release from the YugabyteDB releases page and substitute it below (use the aarch64 package on ARM droplets):
VERSION=<release-version>
BUILD=<build-number>
wget https://software.yugabyte.com/releases/$VERSION/yugabyte-$VERSION-$BUILD-linux-x86_64.tar.gz
tar xvfz yugabyte-$VERSION-$BUILD-linux-x86_64.tar.gz
cd yugabyte-$VERSION/
./bin/post_install.sh
Each node is a separate machine, so repeat this on all three.
5. Start the cluster
yugabyted is the simple launcher that manages both server processes. Start the first node, advertising its private IP:
cd yugabyte-<version>/
./bin/yugabyted start --advertise_address=<NODE1_PRIVATE_IP>
On nodes 2 and 3, join the first node:
cd yugabyte-<version>/
./bin/yugabyted start \
--advertise_address=<THIS_NODE_PRIVATE_IP> \
--join=<NODE1_PRIVATE_IP>
Check status on any node:
./bin/yugabyted status
Once the third node joins, you should see three nodes and a replication factor of 3. By default yugabyted starts without authentication or encryption, which is fine for a firewalled demo but not for anything real. Enable both before production.
6. Look at the cluster
The web UIs are bound to private IPs, so reach them through an SSH tunnel from your laptop:
ssh -L 7000:<NODE1_PRIVATE_IP>:7000 -L 5433:<NODE1_PRIVATE_IP>:5433 root@<NODE1_PUBLIC_IP>
Open http://localhost:7000. The Tablet Servers page should show three live servers. Leave the tunnel running; it also lets any Postgres GUI connect to localhost:5433.
7. Create a table and load data
Connect with the bundled client from any node:
./bin/ysqlsh -h <NODE_PRIVATE_IP> -p 5433 -U yugabyte
CREATE DATABASE shop;
\c shop
CREATE TABLE orders (
id UUID DEFAULT gen_random_uuid(),
customer_id INT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id HASH)
) SPLIT INTO 6 TABLETS;
INSERT INTO orders (customer_id, amount)
SELECT (random() * 10000)::int, (random() * 500)::numeric(12,2)
FROM generate_series(1, 200000);
SELECT count(*) FROM orders;
That is plain Postgres SQL. The only YugabyteDB-specific parts are PRIMARY KEY (id HASH), which hash-distributes rows, and SPLIT INTO 6 TABLETS, which pre-splits the table so the sharding is visible immediately. Without it, YugabyteDB starts small and splits automatically as data grows.
8. See the shards
./bin/yb-admin \
--master_addresses <N1>:7100,<N2>:7100,<N3>:7100 \
list_tablets ysql.shop orders 0 include_followers
You'll see six tablets, each covering a slice of the hash space, each with its own leader and two followers. The UI's Tables → orders page shows the same view.
One caveat is worth understanding. With exactly three nodes and RF=3, every node holds a replica of every tablet, so each node holds all the data. That is replication at work, not a failure of sharding. The independent hash ranges, leaders, and Raft groups are what make this sharded. You'll see the data genuinely partitioned across nodes once you add a fourth node (the balancer redistributes tablets in the background) or run more nodes than the replication factor.
9. Kill a node
With a query running against node 1, stop node 3:
# on node 3
./bin/yugabyted stop
Run the count again from node 1 or 2:
SELECT count(*) FROM orders;
INSERT INTO orders (customer_id, amount) VALUES (1, 9.99);
Both succeed. Any tablets whose leaders lived on node 3 elected new leaders on the surviving nodes, and with two of three replicas alive the Raft majority is intact. Re-run list_tablets and you'll see leaders have moved. Then restart node 3 with the same start command and it catches up and rejoins.
What to remember
- Replication factor determines fault tolerance. RF=3 survives one node failure. RF=2 survives none, because the majority of two is two. Use odd values.
- The sharding is automatic, but the transaction and consensus overhead is real. Benchmark with your own workload.
- For applications, YugabyteDB provides "smart" drivers (forks of standard Postgres drivers) that discover all nodes and load-balance connections, so you don't need to run a separate load balancer.
- Adding capacity is the payoff. Start another node with
--join, and the cluster rebalances tablets onto it with no application changes.
Conclusion
If you're hitting the limits of a single Postgres primary and don't want to shard by hand, YugabyteDB is worth a serious evaluation. The three-node demo above takes under an hour and gives you a real cluster to test your own schema and workload against.
Top comments (0)