Database Connection Pool: Concepts, Common Problems, and Best Practices (Golang + GORM Examples)
In modern backend applications, the database connection pool is one of the most crucial yet often underestimated components. Many performance problems, high latency, even production outages aren’t caused by slow queries, but by misconfigured connection pools. This article discusses the connection pool concept realistically — not just a textbook definition — along with the common problems that arise when it isn’t tuned correctly, how it works in Go, implementation examples with GORM, and the right way of thinking to determine ideal configuration values.
What Is a Database Connection Pool?
Simply put, a connection pool is a collection of database connections that the application maintains and reuses, rather than opening and closing one on every new request. Without a pool, every request opens a new connection to the database, runs its query, then closes the connection when done — a process carrying huge overhead because every new connection requires a TCP handshake, TLS negotiation, and authentication from scratch.
With a pool, connections are created up front, stored in the pool, then reused by many requests in turn. A request needing a connection “borrows” one from the pool, uses it to run its query, then returns it to the pool when done — rather than permanently closing the connection.
sequenceDiagram
participant App as Application
participant Pool as Connection Pool
participant DB as Database
App->>Pool: Request a connection
alt Connection available in the pool
Pool-->>App: Hand over an existing connection
else Pool empty, hasn't reached MaxOpenConns
Pool->>DB: Open a new connection
DB-->>Pool: Connection ready
Pool-->>App: Hand over the new connection
end
App->>DB: Run the query
DB-->>App: Query result
App->>Pool: Return the connectionThe Production Reality
Several facts below often escape attention until problems actually occur in production:
- Database connections are expensive — far more expensive than creating a new goroutine in Go
- Databases have a maximum connection limit they can handle simultaneously
- Too many connections will overload the database
- Too few connections will make requests queue up, raising latency
A connection pool is fundamentally a tool for controlling the pressure the application applies to the database — not just an optional performance optimization feature.
Common Problems Without or With Misconfigured Connection Pools
Default Settings Are the Most Dangerous
Many engineers assume that because they’re using GORM or database/sql, the pool configuration is automatically safe. In fact, Go’s database/sql defaults are potentially dangerous if not adjusted: MaxOpenConns defaults to 0, meaning unlimited, MaxIdleConns defaults to just 2, and there’s no clear default timeout for connection lifetime.
This combination is dangerous because during a traffic spike, the application can open thousands of unlimited connections until the database runs out of resources. From the application side, the symptoms often look like confusing “random timeouts”, when the root cause is actually an uncontrolled connection count.
Max Connection Too Large
A common scenario: the database has a 100-connection limit, while the application runs on 5 pods, each set with MaxOpenConns = 50. Mathematically, the maximum total connections possible is:
5 pods × 50 MaxOpenConns = 250 connections
This number far exceeds the 100-connection limit the database provides. Once traffic is high enough that all pods try to use connections near their respective limits, the database will immediately run out of connection slots and refuse new connections — not because of heavy queries, but because the connection count exceeds capacity.
Max Connection Too Small
The opposite is also problematic. If MaxOpenConns is set too small compared to actual needs, many goroutines will wait for an available connection. As a result, the CPU looks idle (because waiting goroutines don’t actively use CPU), but latency stays high because requests must queue before they can actually execute queries.
This problem happens especially in Go because goroutines are very cheap to create — thousands can run simultaneously without significant issues — while database connections remain expensive and limited. The ease of creating goroutines often makes developers forget that resources on the database side don’t automatically scale with the number of goroutines created.
Don’t immediately blame slow queries when you see high latency with low CPU. This combination is often an indication of a connection pool bottleneck — goroutines waiting for an available connection, not waiting for a heavy query result.
How Connection Pools Work in Go
In Go, the connection pool is actually managed by the database/sql package, not by an ORM like GORM. GORM only wraps database/sql to provide a more convenient API — all pool settings are still done at the sql.DB level, the object that represents the connection pool itself.
flowchart LR
A[Application code] --> B[GORM]
B --> C[database/sql]
C --> D[Actual database connection]The practical implication is important to understand: if you use GORM, you still must configure the connection pool explicitly. GORM doesn’t automatically provide a safe pool configuration just because it runs on top of database/sql — the database/sql defaults remain in effect until manually changed.
Golang + GORM Implementation Examples
Database Setup
Initializing a database connection with GORM starts as usual:
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
Because all pool configuration happens at the sql.DB level, the next step is retrieving that object from the GORM instance:
sqlDB, err := db.DB()
if err != nil {
log.Fatal(err)
}
The db.DB() method returns a pointer to the *sql.DB that GORM uses behind the scenes. This is the object used for all pool configuration in the next sections.
Connection Pool Configuration (Required)
Max Open Connections
sqlDB.SetMaxOpenConns(20)
This configuration sets a maximum of 20 active connections that can be open to the database simultaneously. If all 20 connections are in use and a new request needs a connection, the request will wait until one of the in-use connections is returned to the pool.
Best practice for this value: don’t use a large number without a clear reason. Calculate based on two main factors — the maximum connection limit allowed by the database, and the number of application instances running simultaneously, since each instance will have its own separate pool.
Max Idle Connections
sqlDB.SetMaxIdleConns(10)
This configuration determines how many idle connections (not currently in use) remain stored in the pool rather than being closed immediately. Keeping idle connections avoids the overhead of opening a new connection from scratch on every request, since existing connections can be reused directly.
Best practice: usually set to around 50–75% of MaxOpenConns, and never larger than MaxOpenConns itself — logically there’s no point storing more idle connections than the total maximum allowed.
Connection Max Lifetime
sqlDB.SetConnMaxLifetime(30 * time.Minute)
This configuration forces every connection to be closed after a certain period, regardless of whether the connection is actively in use or not. The goal is preventing problems from “old” connections — connections open too long risk undetected network issues, or being forcibly cut by load balancers or database proxies with their own timeout policies.
Best practice: common values range 15–60 minutes, and must be smaller than the idle timeout applied on the database side or database proxy (like PgBouncer). If this value is larger than the database-side timeout, the connection may have already been forcibly closed by the database while the application still considers it valid.
Connection Max Idle Time (Go ≥ 1.15)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
This configuration closes connections that have been idle (unused) for too long, even if they haven’t reached ConnMaxLifetime. The goal is saving resources — rarely used connections don’t need to be kept open if there’s no traffic requiring them.
Best practice: 5–15 minutes is generally enough, and this option suits unstable traffic well — quiet periods followed by traffic spikes — because unused connections during quiet periods close automatically, reducing idle load on the database.
Example of an (Generally) Ideal Configuration
For one service instance with medium traffic, here’s a configuration combination often used as a starting point:
sqlDB.SetMaxOpenConns(20)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
This combination isn’t a magic number that applies universally — it’s just a reasonable starting point to then adjust based on actual traffic characteristics and database limits, following the way of thinking explained in the next section.
How to Determine the Right Values
Look at the Database Limit
The first step is checking the maximum connection limit allowed by the database. For example for PostgreSQL:
Postgres max_connections = 100
This value can be checked via the postgresql.conf configuration or by querying SHOW max_connections; directly against the database.
Count the Number of Instances
Next, count how many application instances will connect to that database simultaneously, for example:
5 application pods
Divide Safely
Divide the database limit by the number of instances to get an initial MaxOpenConns estimate per instance:
100 / 5 = 20
So MaxOpenConns = 20 becomes a reasonable starting point. But don’t use the entire capacity directly — leave a buffer for other needs that also use connections to the same database, such as migration processes, manual admin access, or background jobs running separately from the application’s main traffic.
Adjust to Traffic Characteristics
The division result above still needs adjustment based on the types of queries being run. If most queries are fast (below tens of milliseconds), a smaller MaxOpenConns is usually still sufficient because connections return to the pool quickly. Conversely, if there are heavy or slow queries (complex reports, large aggregations), don’t set MaxOpenConns too large for these query types, because each connection will be held longer, approaching the set limit faster.
Anti-Patterns to Avoid
✗ Not configuring the connection pool at all, relying on database/sql defaults
✗ Setting MaxOpenConns to a large number "to be safe" without calculation
✗ Assuming the database can autoscale like a stateless application
✗ Using one database for too many services without calculating total capacity
These four anti-patterns share a common trait: they all come from assuming the database has the same elasticity as stateless application components. Applications can scale by adding new pods or instances almost without limit, but databases don’t work that way — their connection capacity remains limited to a single point (or cluster), and every new application instance added actually adds pressure to that same capacity, not automatically dividing it.
Observability: Don’t Fly Blind
Correct initial configuration isn’t enough without ongoing monitoring, because traffic characteristics can change over time. Several metrics that must be monitored:
- Number of active connections (connections currently in active use)
- Number of idle connections (connections idle in the pool)
- Wait time — how long requests wait before getting a connection from the pool
Most Go database drivers provide these metrics via the sqlDB.Stats() method, which returns a struct containing information like OpenConnections, InUse, Idle, and WaitDuration — these metrics can be exposed to monitoring systems like Prometheus for continuous monitoring.
The pattern to watch out for: if CPU is low, latency is high, and many goroutines are in a blocked state waiting for something, the bottleneck is likely at the connection pool — not at the queries or application compute resources. This symptom is often misinterpreted as a code performance problem, when the solution lies in the pool configuration, not query optimization.
Summary
- A connection pool is a collection of database connections that get reused, avoiding the overhead of opening a new connection on every request.
- Go’s
database/sqldefaults (MaxOpenConnsunlimited,MaxIdleConns = 2) are risky in production and must always be explicitly reconfigured.- GORM doesn’t manage the connection pool automatically — configuration still happens via the
*sql.DBretrieved fromdb.DB().- Calculate
MaxOpenConnsbased on the database connection limit divided by the number of application instances, leaving a buffer for migrations and background jobs.MaxIdleConnsshould ideally be 50–75% of MaxOpenConns;ConnMaxLifetimeandConnMaxIdleTimeprevent “old” connections and save resources during quiet traffic.- Avoid anti-patterns like not configuring the pool at all, or setting
MaxOpenConnstoo large “to be safe” without calculation.- Monitor active connections, idle connections, and wait time — low CPU with high latency is often a sign of a connection pool bottleneck, not queries.
- Rule of thumb: better a request briefly waits for a connection than the database dies from too many connections.