The next version of LeverLoop is coming soon! Be the first to try it →

Scaling Postgres Beyond One Billion Rows

How we partitioned, indexed, and tuned our Postgres cluster to keep sub-10ms query times at scale.

·12 min read

Quick answer

We scaled Postgres past 1.2 billion rows by range-partitioning tables by workspace_id across 40 partitions, using partial indexes to cut index size by 60%, and adding PgBouncer in transaction mode for connection pooling. This brought p99 query latency back under 10ms with zero-downtime migrations.

Key takeaways

  • 1We serve 1.2 billion rows across 40+ Postgres partitions with p99 query times under 10ms.
  • 2Range partitioning by workspace_id gave us the best balance of query performance and operational simplicity.
  • 3Partial indexes on hot columns reduced index size by 60% and improved write throughput by 35%.
  • 4Connection pooling with PgBouncer in transaction mode was essential beyond 500 concurrent connections.
  • 5We run zero-downtime migrations using pg_repack and careful lock management.
  • 6Monitoring query plans in production (not just staging) caught three critical regressions before they hit users.

Who this is for

  • Backend and infrastructure engineers scaling Postgres for multi-tenant SaaS
  • Database administrators managing billion-row tables with growing latency
  • Engineering leaders evaluating whether to stay on Postgres or migrate to a distributed database

What does scaling Postgres past a billion rows look like?

At LeverLoop, Postgres is our primary datastore. It holds contacts, deals, emails, activity logs, and search indexes for every workspace. By late 2025, our largest tables crossed one billion rows — and query performance was starting to degrade.

The symptoms were familiar: p99 latencies climbing from 8ms to 45ms, vacuum operations running for hours, and index bloat consuming 3x the space of the actual data. We needed a strategy that would buy us at least 10x headroom without a full re-architecture.

Why we chose range partitioning by workspace_id

We evaluated three partitioning strategies: hash, list, and range. Range partitioning by workspace_id won because nearly every query in our application is scoped to a single workspace, which means Postgres can prune partitions at plan time and only touch the relevant subset of data.

  • Hash partitioning distributes data evenly but doesn't help with workspace-scoped queries.
  • List partitioning gives precise control but requires manual partition management as workspaces grow.
  • Range partitioning balances even distribution with automatic pruning and simple operational management.

After migrating our three largest tables to 40 range partitions, p99 query times dropped from 45ms back to under 10ms — even as data continued to grow.

Partial indexes and connection pooling at scale

Partitioning solved the scan problem, but we still had index bloat and connection pressure. Two changes made the biggest impact:

Partial indexes on hot columns — like WHERE status = 'active' — reduced total index size by 60%. Since most queries filter on active records, the partial indexes serve the same queries with a fraction of the storage and maintenance cost.

PgBouncer in transaction mode became essential once we passed 500 concurrent connections. Without it, Postgres spent more time managing connections than executing queries. Transaction-mode pooling reduced active backend connections from 800+ to under 100, freeing resources for actual work.

How we run zero-downtime migrations on billion-row tables

Schema changes on large tables are where most teams hit operational pain. A naive ALTER TABLE can lock a billion-row table for hours. We use a combination of pg_repack for table rewrites and careful lock management for additive changes.

  1. Additive changes (new columns, new indexes) use CREATE INDEX CONCURRENTLY and ALTER TABLE ... ADD COLUMN with defaults — both non-blocking in Postgres 11+.
  2. Type changes and rewrites use pg_repack, which rebuilds the table in the background and swaps it atomically.
  3. Destructive changes (dropping columns) are deferred to maintenance windows with explicit lock timeout guards.

We haven't had a migration-related outage in 14 months using this approach.

Why we monitor query plans in production, not just staging

Staging environments lie. They have different data distributions, different table statistics, and different connection patterns. Three of our worst performance regressions were invisible in staging but immediately apparent in production query plans.

We now run pg_stat_statements with custom alerting on:

  • Any query whose mean execution time increases by more than 2x week-over-week.
  • Any query that triggers a sequential scan on a table with more than 1M rows.
  • Any new query pattern (not seen in the previous 7 days) that exceeds 50ms.

This monitoring has caught regressions from ORM-generated queries, unexpected index drops, and autovacuum configuration drift — all before users noticed.

Frequently asked questions

JP

Written by

James Park

Staff Engineer

James is a staff engineer at LeverLoop focused on performance, scalability, and infrastructure. He previously worked on distributed systems at Google and Cloudflare.

Connect on LinkedIn