Back to Writing

Explicit Connection Pooling in Go: Fixing Cross-Tenant Data Contamination

A deep dive into fixing cross-tenant data contamination in Go—using the Tenant Registry pattern to trade memory footprint for absolute system integrity.

From Shared Pools to Resource Silos

In a standard Go application, *sql.DB is not a single connection; it is a thread-safe connection pool manager. Most applications initialize one global pool and share it across all goroutines. While efficient, this assumes a single target database.
When a system must handle multiple tenants with dedicated databases, the architecture must evolve from a single manager to an "Array of Managers." In this model, every tenant has its own isolated *sql.DB instance, creating a physical silo where internal mutexes and idle connection lists are completely separated.

The Registry Pattern: How it Works

The core of this architecture is the shift from "Mutation" (changing a global state) to "Selection " (resolving a resource from a registry). This is managed via a thread-safe lookup table.
go
type TenantRegistry struct {
    pools map[string]*sql.DB
    mu    sync.RWMutex
}

func (r *TenantRegistry) GetPool(id string) (*sql.DB, error) {
    r.mu.RLock()
    pool, exists := r.pools[id]
    r.mu.RUnlock()

    if exists { return pool, nil }

    r.mu.Lock()
    defer r.mu.Unlock()
    // Double-check to prevent race during init
    if p, ok := r.pools[id]; ok { return p, nil }

    newPool, _ := sql.Open("postgres", getDSN(id))
    r.pools[id] = newPool
    return newPool, nil
}

Enforcement via Dependency Injection

The registry provides safety, but Dependency Injection provides enforcement. By binding the repository to a specific *sql.DB instance at instantiation, you create a compiler-enforced silo.
The service layer or middleware resolves the pool and injects it into the repository. Once injected, the repository is physically incapable of querying the wrong database because it has no access to any other state.
go
type OrderRepository struct {
    db *sql.DB // Scoped to a specific tenant
}

func (repo *OrderRepository) Fetch() {
    // Compiler-enforced: no global state is accessed
    repo.db.Query("SELECT * FROM orders")
}

The "Silo Tax": Pragmatic Trade-offs

Maintaining multiple simultaneous pools involves trading Resident Set Size (RSS) memory for reliability and speed. It is a classic engineering calculation.
  • Handshake Elimination: By keeping pools "warm," you eliminate the 20ms-100ms TCP/TLS handshake latency. Queries start instantly.
  • Granular Governance: You can set MaxOpenConns(50) for a high-traffic tenant while restricting a trial tenant to MaxOpenConns(2).
  • The Multiplier Effect: NN tenants ×\times MM idle connections can lead to socket exhaustion. Tuning SetMaxIdleConns(1) and SetConnMaxLifetime() is mandatory.

Scaling for Exponential Growth

A single registry works for hundreds of tenants, but thousands require Horizontal Sharding . In this scenario, you group tenants into clusters and deploy sharded backend instances.
Each shard only manages the "warm" pools for its specific subset of tenants. This maintains the explicit pooling model while keeping the memory footprint of individual instances manageable.

The Bottom Line

In software engineering, explicit is always safer than implicit. Shifting to siloed connection pooling allows you to align your architecture with Go’s concurrency model. You trade a higher memory footprint for a system that is fundamentally honest, faster, and immune to the race conditions that plague global state mutation.

© 2026 — This site documents my work and thinking around software system.

Open to senior full-stack web engineering roles — [email protected]Privacy Policy