← All Posts

How We Built a Resilient PostgreSQL Layer in Go: Lessons From Scaling to Production

The hardest part of scaling a Postgres-backed Go service wasn’t SQL — it was connection behavior. Here’s the journey of uncovering that truth and the approach that finally worked.

Published by Subrat Prasad on 2025-12-02

AI Generated using Midjourney

Most engineering teams underestimate database connections.

Queries get attention. Indexes get attention. Schema design gets attention. But the connections between your app and the database?

They break first, cause the worst tail latencies, and trigger the nastiest outages.

When we were building our Go backend on PostgreSQL and AWS RDS, we assumed connection handling was “solved.” Go has a built-in connection pool. Postgres is mature. RDS Proxy handles multiplexing.

Reality: it took us months of iteration — and a few painful incidents — to build a connection layer that was truly resilient.

This is the story of that evolution, told in a way that you can reuse for your own systems.

Background

Before diving into the story, here’s the relevant snapshot of our stack:

  • Language: Go
  • ORM: GORM (for model mapping + ergonomics)
  • Driver & Pooling: pgx + pgxpool (for control + performance)
  • Database: PostgreSQL hosted on AWS RDS
  • Connection Infra: AWS RDS Proxy
  • Runtime: Mix of long-running ECS services + short-lived Lambda functions

1.The Naive Beginning: database/sql Defaults

Our first version used:

  • the Go standard library (database/sql)
  • default settings
  • one pool for everything
  • no reader/writer separation

On paper this looked fine. Under load it fell apart.

Issues we hit immediately:

  • frequent reconnections
  • spikes in p95/p99 latency
  • connection exhaustion on traffic bursts
  • unpredictable behavior during deployment rollouts

The root cause was simple:

The standard library’s pool is very limited.

  • No MinConns.
  • No health checks.
  • Minimal insight into connection states.

With the existing settings, it opens connections lazily and drops them aggressively.

This led us into our first “upgrade”… except it wasn’t really an upgrade.

1.5. The Hacky Connection Warm-Up

Before switching to anything new, we tried to work around the limitations of database/sql.

The biggest missing feature was MinConns. Our app frequently started with zero open connections, causing a thundering herd of new connections during traffic spikes.

So we wrote a warm-up hack:

for i := 0; i < maxConns; i++ { _, _ = db.QueryContext(ctx, "SELECT 1") }

This forced the pool to open multiple connections early during the application startup.

Why this wasn’t a real upgrade

It was a patch, not a solution and that too a very limited one. It was limited to application startup along with other shortcoming.

It didn’t fix the deeper issues:

  • idle timeouts still killed connections
  • the pool could still shrink to zero
  • behavior varied across environments
  • it masked the real problem instead of solving it

This phase was important because it taught us what we truly needed: predictable connection behavior, not hacks.

That realization moved us to the real next step.

2.Switching to pgxpool: Real Control at Last

Migrating to pgxpool was the first time our database layer started behaving like a production system.

With pgxpool, the essentials were finally in our control:

MinConns // maintain warm pool 
MaxConns // cap peak usage 
MaxConnIdleTime // prevent churn HealthCheck // keep connections alive

Three immediate improvements:

  1. Warm pools stayed warm. No more reconnect storms during traffic bursts.
  2. Deployment rollouts no longer caused latency spikes. New tasks pre-initialized connections.
  3. Idle timeout was no longer a silent killer. We tuned idle time to avoid constant churn.

This alone solved a huge class of problems — but we uncovered a new one.

3.A Subtle GORM Behavior We Learned Along the Way

As our system matured, we eventually learned something about how GORM handles database connections that isn’t obvious at first glance.

In our early setup, we passed a plain DSN into GORM:


setup db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})

This works perfectly fine — but what we didn’t realize initially is that:

GORM creates and manages its own internal connection pool.

It wasn’t causing issues or errors, but it did mean:

  • we were maintaining two sets of connection pools
  • resource usage was a bit higher than needed
  • connection behavior wasn’t fully unified across the system

Nothing broke — it was just not the most efficient setup once we started caring about pool tuning.

The fix was simply letting GORM use the same pool we manage with pgxpool

// New setup 
sqlDB := stdlib.OpenDBFromPool(pgxPool) db, err := gorm.Open(postgres.New(
  postgres.Config{ Conn: sqlDB, // GORM now uses our pgxpool 
  PreferSimpleProtocol: true, // optional, but helps performance 
}))

After this change:

  • all connections came from a single pool
  • tuning became consistent
  • resource usage became more predictable

A small detail — but one that makes the entire architecture cleaner. one pool, one lifecycle, full visibility.

4.Splitting Reader and Writer Pools: The Scaling Unlock

Next up, is an obvious and natural one. But we did not do this because we ran into scaling issue on read queries.

When we procured the AWS RDS instance, we procured a reader instance along with the writer. Anticipating that we will, at some point, have to split out the traffic to reader and writer instance, we did it pre-maturely.

So we made the architectural shift:

Two independent pools: Reader and Writer

Each with its own:

  • DSN
  • pgxpool instance
  • tuning strategy
  • monitoring panel

This immediately gave us:

  • stable write performance
  • better horizontal scaling (especially with replicas)
  • clear observability: “read pool is saturated” ≠ “write pool is saturated”

5.Service-Specific Pool Configurations

Different workloads need different pool strategies.

We had a single golang db management implementation that was shared by multiple binaries. We started with a common configuration for all binaries. However, we soon realized that the workload patterns are different from these binaries. We had long running services serving user requests synchronously. Connection pools are very important in these situations to serve user requests quickly. We also had short-lived lambdas that did background work asynchronously. Although, our lambdas could server multiple request in their lifetime, these use-cases are not latency sensitive.

Long-running services (Fargate/ECS)

MinConns = high 
MaxConns = high 
IdleTime = moderate

Warm pools drastically improve latency.

Short-lived services (Lambda consumers)

MinConns = 1 
MaxConns = low 
IdleTime = near zero

They shouldn’t hold 25 idle connections between invocations.

Pool configuration is not a one-size-fits-all concept. It’s tied to your service’s shape and runtime environment.

6.The RDS Proxy Two-Level Pool Problem

With multiple binaries talking to the same database instances, we realized we needed a protection layer for our db. The protection layer will shield the db from connection storms and putting a cap on the db’s resource utilization.

The native AWS way to do this is RDS Proxy. Another popular solution is pgBouncer. Since, we wanted low recurring maintenance cost for our engineering team, we decided to go with RDS Proxy.

This definitely introduced more complexity. Connections now flowed through two layers of pooling:

service → pgxpool → RDS Proxy → PostgreSQL

You now have to think about:

  • idle timeouts interacting
  • which layer “owns” connection health
  • how warm pools behave across layers
  • how many actual DB connections RDS Proxy maintains

Our conclusion for a high-throughput Go service:

  • Keep pgxpool for predictability.
  • Use RDS Proxy for multiplexing.
  • Tune both intentionally
  • Removing pgxpool made the system fragile.
  • Relying only on RDS Proxy removed application-level control and poor performance for end user experience

7.Ubiquitous observability

There isn’t any replacement for observability. Once we exposed pgxpool metrics and traced connections, everything snapped into place.

You can’t fix what you can’t see.

stats := pool.Stat()

This made tuning scientific instead of guesswork. We could see:

  • pool saturation
  • wait time on acquire
  • new vs reused connections
  • canceled acquires
  • idle churn
  • connection spikes
  • reader vs writer pool imbalance

Takeaways for Anyone Building a Production Go + Postgres System

These lessons came directly from real production incidents and real scaling pain:

Use pgxpool early


The standard library pool is too limited for production systems.

Unified connection pool GORM-PGX


Otherwise you will unintentionally maintain two pools.

Split reader and writer pools


This improves performance and observability.

Tune pgxpool and RDS Proxy together


Treat them as a two-layer system.

Tune pools per service type


Different workloads have different usage pattern and generate different resource consumption.

Add observability


Without pool metrics, you are operating blind.