← Back to blog

    Horizontally Scaling PostgreSQL: A Practical Decision Framework (Without Sharding Too Early)

    DatabasesScaling
    Illustration: the spectrum of horizontally scaling PostgreSQL from replicas and pooling to sharding and distributed SQL

    PostgreSQL is incredibly capable, but it’s also honest about what it is: a single-node relational database with strong primitives for reliability, read scaling, and migrations.

    When teams say they want to “horizontally scale Postgres”, they usually mean one (or more) of these:

    • More read throughput (or offloading analytics/reporting)
    • More concurrent connections (connection storms, too many app instances)
    • More write throughput (hot tables, high ingest)
    • More total data (bigger-than-a-single-machine storage/IO limits)
    • More availability / geo footprint (HA, multi-AZ, multi-region constraints)

    Those are very different problems—and they have very different solutions.

    This post gives you a decision framework I’ve seen hold up in real systems:

    1. Scale reads + connections (cheap, high ROI)
    2. Scale within one node (indexes, query design, partitioning)
    3. Only then consider write horizontal scale (sharding / distributed)

    And we’ll focus on the real cost of “horizontal scaling”: operational complexity (joins, migrations, resharding, backup/restore, failure modes).

    Step 0: Be explicit about the bottleneck

    Before you pick a “scale-out” architecture, slow down and make the problem explicit. What’s actually hurting right now—CPU from query execution, IO, lock contention, connection churn, or just raw data size? Then separate throughput problems from tail-latency problems: if you’re missing p95/p99, you’ll usually get more mileage from fixing query shapes, indexes, long transactions, and connection behavior before you add nodes. If latency is fine but you can’t push enough volume, then yes—you may genuinely be outgrowing a single machine.

    If the system only melts down during deploys or autoscaling events, that’s often not “Postgres can’t scale”—it’s a sign you need pooling or a proxy to soak up connection spikes and make failovers less dramatic.

    The spectrum: “horizontal scale” is not one technique

    Think of horizontal scaling as a spectrum from “still one primary database” to “you’re running a distributed database system”. The trick is to move along this spectrum on purpose: start with the steps that buy you a lot of headroom with minimal changes, and only take on distributed complexity when you’ve proven you actually need it.

    • Read scale: add read replicas and push read-heavy work (dashboards, reporting, background jobs) away from the primary—while being honest about replica lag and stale reads.
    • Connection scale: add a pooler/proxy so your database sees a stable number of connections even if your app scales out aggressively (and so failovers don’t turn into connection-storm chaos).
    • Bigger single node: tune queries and indexes, fix lock contention, and upgrade CPU/RAM/storage—then use partitioning to make very large tables and retention/vacuum more manageable (still one database, just healthier).
    • Data movement / migrations: use logical replication (blue/green) when you need to move or upgrade Postgres with minimal downtime—treating schema changes and cutovers as an operational choreography, not a copy-paste weekend.
    • Write horizontal scale: shard (with an application router) or use a distributed layer (like Citus) once you have a stable routing key and you can keep most OLTP requests single-shard—because cross-shard joins/transactions are where complexity explodes.
    • Fully distributed SQL: pick a database designed to be distributed when you need cross-node consistency and transactions as a first-class feature (for example, Google Spanner or CockroachDB)—knowing you’re trading “it’s Postgres” for “it’s compatible enough, but different in important ways”.

    The mistake is skipping straight to the far right because the diagram looks cool.

    Step 1: Scale reads and connections first (the boring win)

    If your write path is healthy but the primary is drowning in read traffic or connections, you can often get a huge improvement without changing your data model.

    Reference architecture: read replicas + pooler/proxy

    Figure 1: Start here in most cases—replicas for reads, pooler/proxy for connection pressure

    What this solves

    This is the “make Postgres feel boring again” set of moves. You’re not changing your data model—you’re mostly changing where traffic goes, and how connections are handled.

    • Read QPS: move read-heavy workloads (dashboards, search-ish queries, reporting, background jobs) onto replicas so the primary can spend its budget on writes and critical reads.
    • Connection storms: keep a small, stable number of database connections even when your app scales out (or during deploys) by pooling at the edge, instead of letting every instance open its own little connection bonfire.
    • Cleaner failovers: a proxy/pooler can reduce the blast radius of failover by centralizing connection handling—apps still see errors for in-flight work, but you avoid the “everything reconnects at once” stampede.

    What it doesn’t solve

    This is still a single-writer system. You get breathing room, but you don’t magically get infinite write capacity.

    • Write throughput is still limited by the primary: replicas don’t help your hot insert/update path.
    • Hot rows/tables remain hot: if one table (or even one row) is the contention point, replicas won’t fix the underlying lock/IO/CPU problem.
    • Lag-aware correctness becomes your responsibility: once reads are served from replicas, you need to decide where you can tolerate stale data and where you need read-your-writes behavior (and build routing rules accordingly).

    Step 2: Delay sharding by scaling “inside” Postgres

    Before you distribute data across nodes, squeeze the obvious wins out of a single database.

    Do the unsexy work

    This is where most scaling wins live, and it’s also the stuff that doesn’t make for exciting architecture diagrams. The upside is: it’s usually the fastest path to real improvements, and it keeps your system simple.

    • Add the right indexes (and remove the wrong ones): index for how you actually query, not how you wish you queried—and don’t carry dead indexes that slow down writes.
    • Fix query shapes (avoid unnecessary N+1s, reduce over-fetch): make the database do less work per request, and make your hot paths predictable.
    • Track p95/p99 latency, not just average: the average hides the “random slowness” your users complain about; tail latency is where locks, IO, and cache misses show up.
    • Check lock contention and long-running transactions: if one request holds a transaction open too long, it can quietly poison the rest of the system.
    • Fix connection management (timeouts, pool sizing, retry safety): the cleanest query in the world still hurts if you’re thrashing connections or retrying in a way that multiplies load.

    Partitioning is not horizontal scale—but it buys you time

    Partitioning is a way to split one logical table into multiple smaller physical tables (partitions), usually based on a key like time (e.g., created_at) or an ID range. To the application it still looks like one table, but operationally and performance-wise it behaves more like a set of manageable chunks.

    Partitioning is still one database, but it can make a huge difference once tables get big enough that routine operations become painful. Think of it as “reduce blast radius” rather than “add machines”.

    • Partition pruning reduces scan cost: queries that filter by the partition key can skip most of the table and stay fast even as data grows.
    • Operational tasks (vacuum, retention, archival) become easier: you can drop/archive partitions instead of running expensive deletes, and you can target maintenance more precisely.
    • Reduce the blast radius of “big table” pain: fewer runaway queries and fewer maintenance surprises that spill into unrelated parts of the database.

    Partitioning often removes enough pain that you can postpone (or avoid) a distributed rewrite.

    Step 3: If writes are the bottleneck, you’re choosing your complexity

    True horizontal write scaling means you’re no longer “just running Postgres”—you’re running a system around Postgres. The data (and often the transactions) are distributed, and that’s where the hidden costs show up.

    • cross-shard transactions: the moment one business operation touches two shards, you’re in distributed coordination land (two-phase commit, sagas, or “we redesigned the workflow”).
    • cross-shard joins: joins that used to be a single query can turn into fan-out requests, data shipping, or precomputed/denormalized views—and OLTP performance can fall off a cliff if you’re not careful.
    • rebalancing / resharding: adding capacity isn’t just “add a node”; you need to move data, update routing, and do it without breaking correctness or SLOs.
    • schema migrations across shards: every migration becomes N migrations, plus sequencing and verification—one missed shard is all it takes for weird production-only bugs.
    • shard-aware backups + restores: backups are now a set of backups plus metadata (shard maps, routing config), and restores become multi-step runbooks you should rehearse.
    • more complicated failure modes: partial failures become normal (one shard down, one shard lagging, one shard overloaded), and your app needs to degrade gracefully instead of just timing out.

    There are two common paths.

    Path A: Application-level sharding (router + many Postgres primaries)

    Application-level sharding is the “you run multiple Postgres databases and your app knows where the data lives” approach. Instead of one giant primary, you split tenants/entities across multiple primaries, and every request carries (or derives) a routing key so it can be sent to the right shard.

    This is the classic model: your application (or a routing layer) decides which shard owns a request.

    Figure 2: Sharding succeeds or fails based on one thing: a stable routing key

    When it works well

    App-level sharding works beautifully when your product and data model make routing obvious and stable.

    • You have a strong routing key (often tenant_id): it shows up in almost every request, it’s easy to pass through your services, and it matches how customers actually use the product.
    • You can design so most requests touch one shard: your “hot path” transactions stay local to one Postgres, so you keep the nice things (ACID, simple joins, predictable performance) where it matters.
    • You can accept that some queries become “global reports” (ETL/analytics) instead of OLTP queries: cross-shard aggregations still exist, but you treat them as batch/analytics workloads with their own pipeline and expectations.

    Where teams get hurt

    This is where sharding stops being a scaling trick and turns into a long-running engineering program.

    • You cannot reliably carry the routing key through your APIs: once the key gets lost (or arrives late), you start doing scatter-gather queries, and your performance becomes unpredictable.
    • You need cross-shard joins in OLTP paths: the moment a user request needs data from “two different places”, you’re forced into denormalization, app-side joins, or expensive fan-out reads.
    • You need “global uniqueness” constraints that span shards: what used to be a single unique index becomes an application-level problem (and those are easy to get subtly wrong).
    • You need frequent resharding (bad key choice or fast growth): moving tenants/entities safely is doable, but it’s operationally heavy—data movement, dual-writes or cutovers, and lots of verification.

    If you choose app-level sharding, treat the router and the global metadata (shard map) as tier-0 infrastructure.

    Path B: A distributed Postgres layer (Citus-style)

    One popular option here is Citus: an extension that turns Postgres into a distributed database by spreading tables across worker nodes and coordinating queries through a coordinator node.

    More generally, a distributed extension can give you a “single logical database” feel while distributing data across worker nodes.

    Figure 3: Coordinator/worker model for distributed tables

    What this buys you

    A distributed layer is basically “sharding, but with a lot of the plumbing built for you”. It won’t remove trade-offs, but it can reduce how much custom infrastructure you have to write and maintain.

    • Easier query surface than DIY sharding: your application can often keep writing SQL that looks normal, instead of pushing routing and fan-out logic into every service.
    • Distributed execution for many queries: for the right workload, the system can parallelize work across workers so you get more CPU and IO than a single box can offer.
    • Operational primitives for shard placement and rebalancing (tooling-dependent): adding nodes, moving shards, and managing placement can be supported by built-in commands and tooling rather than bespoke scripts.

    What you still must design for

    This is where teams get surprised: the distributed layer makes some things easier, but it also forces you to be intentional about data modeling.

    • Pick the right distribution key (this is everything): it decides whether your hot path is fast and local, or accidentally becomes “every request touches multiple nodes”.
    • Keep OLTP queries single-shard whenever possible: the best clusters are the ones where most user requests don’t need coordination across workers.
    • Know which joins are cheap (co-located / reference) vs expensive (repartition/scatter): you can still do the expensive joins, but you want to keep them out of latency-sensitive paths.

    A distributed layer can reduce how much sharding infrastructure you write yourself, but it doesn’t remove the fundamental truth:

    Distributed systems turn your database into a networked system. Your query plans now have network in them.

    A comparison lens that avoids “religious debates”

    Instead of arguing tools, compare approaches on the attributes that create (or destroy) production reliability.

    ApproachWhat it’s best atWhat stays simpleWhat gets harder fast
    Read replicasRead throughput, HAData model, writesStale reads, lag-aware routing
    Pooler/proxyConnection storms, fast app scalingSchema, queriesSession semantics, debugging, edge cases
    PartitioningBig tables, retention, pruningACID semanticsOperational discipline (partition lifecycle)
    Logical replication (blue/green)Migrations, cutoversApp code mostly unchangedDDL coordination, sequence catch-up, cutover choreography
    App shardingWrite scale when model supports itPer-shard ACIDCross-shard joins/txns, resharding, backups
    Distributed extensionSharding without DIY routerMany queries remain “SQL-shaped”Distribution key design, multi-node ops
    Distributed SQL DBCross-shard transactions/constraintsGlobal “single DB” feelCompatibility testing, new failure/latency model

    If you want one takeaway from this table:

    • If you can stay in the top three rows, do it.
    • If you must go lower, do it with eyes open—and design for operations, not just the happy path.

    A simple decision flow that prevents premature sharding

    If you’re feeling tempted to jump straight to sharding, you’re not alone—it’s the most visible “scaling move”, and it sounds like the grown-up solution.

    This little flowchart is a sanity check I use to slow teams down and ask the uncomfortable questions first: is it really a write bottleneck, do we have a routing key that actually works in the real product, and are we about to sign up for cross-shard joins and global constraints on the hot path?

    Figure 4: The framework: scale reads first, shard only when the model supports it

    Runbook-shaped guidance: what to do next (in order)

    If you want an actionable sequence, use this progression:

    1. Add observability: slow query logging, top queries, p95/p99, replication lag
    2. Fix connection pressure: pooler/proxy + right connection limits
    3. Add read replicas and route read-heavy workloads
    4. Optimize queries/indexes and remove obvious hotspots
    5. Partition large tables to reduce blast radius of vacuum/retention and improve pruning
    6. If writes still don’t fit:
      • introduce a routing key into your domain model
      • redesign OLTP queries to be single-shard
      • then choose DIY sharding or a distributed extension

    The common anti-pattern is doing step 6 without doing steps 1–5, then discovering your “sharding project” was actually a “we needed pooling and two indexes” problem.

    Blue/green migration pattern (logical replication style)

    Even if you don’t plan to shard, you still need a safe way to move your database without a “pray and deploy” weekend—major version upgrades, instance moves, cloud migrations, or just getting off a struggling box.

    Blue/green with logical replication is the simplest mental model that tends to work in the real world: keep the old database serving traffic, stream changes into the new one, then do a short, controlled cutover.

    Here’s the choreography that keeps teams safe:

    Figure 5: A safe cutover is mostly about write-gating and catch-up verification

    Operational notes that matter in practice:

    • Schema changes are not magic: replication moves data changes, not your DDL discipline. Use an expand/contract approach, apply additive changes in a safe order, and assume “one missed migration” will surface as a production-only bug.
    • Sequences/IDs can drift: if the new database will accept writes after cutover, explicitly verify and fix sequences/identity counters (otherwise you discover the problem as a duplicate key incident).
    • Cutover is a verification step, not just a switch: during the write-gated window, check replication lag is actually zero, sanity-check row counts/invariants for critical tables, and keep the rollback path open until you’ve seen real traffic behave.
    • Rollback is easiest when you avoid divergent writes: keep the old DB intact and don’t allow “writes in both places”. If you avoid divergence, rollback is just routing traffic back; if you allow divergence, rollback becomes a data reconciliation project.

    The failure modes you should plan for (before you ship)

    Horizontal scaling is less about the happy path and more about what happens on a random Tuesday: a node is slow, a replica is behind, a deploy triggers a traffic spike, or someone runs a migration that was “fine in staging”. That’s why the right way to evaluate a scale-out approach is to ask: what are the failure modes, and do we have a playbook for each one?

    These are the failure modes that show up repeatedly:

    • Replica lag causing stale reads and confusing UX: you route reads to replicas and suddenly users see “old” state—so you need lag monitoring, read-your-writes rules for critical flows, and a plan for what to do when lag spikes.
    • Failover causing in-flight transactions to fail (and apps that can’t retry safely): failover isn’t just “the DB came back”; it’s a burst of errors, cancelled statements, and retries—so your application needs idempotency and backoff, not blind retry storms.
    • Hot shards because the distribution key is uneven: one tenant or entity becomes the elephant and you’ve accidentally built a single-node bottleneck—so you need a key that spreads load, plus a strategy for isolating/moving big tenants.
    • Scatter-gather queries accidentally landing in OLTP paths: the query that touches “all shards” sneaks into a user request and your p95 falls off a cliff—so you need guardrails (routing key always present) and a clear separation between OLTP queries and global reporting.
    • Schema drift across shards/nodes (migrations that were “fine locally”): one shard is missing a migration or has a different index and you get weird, shard-specific bugs—so migrations need sequencing, verification, and automation across every node.
    • Backups/restores that restore data but not routing metadata (or restore shards inconsistently): you can restore tables but you can’t serve traffic because the shard map/config doesn’t match—so backups must include metadata, and restores must be rehearsed as a runbook.

    A good architecture doc has, for each of these:

    • The symptom users see
    • The metric/alert that detects it
    • The immediate mitigation
    • The long-term prevention

    If you can’t explain your rollback plan, you don’t have an architecture—you have a hope.

    Enjoyed this? Give it a clap.