Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 10

Configuring pgx/pgxpool for PostgreSQL Connection Pooling

Recommended resources

Hello! Welcome back.

In our last lesson, we put a crucial piece of our application's architecture in place: dependency injection. You created an APIServer that depends on a Storage interface, not a concrete database implementation. We then wired up an in-memory version, proving how flexible this design is.

Today, we're taking the next major step toward a production-ready system. We will replace our temporary in-memory storage with a real, persistent database: PostgreSQL.

Today's Learning Outcome: Set up and configure a PostgreSQL connection pool using pgx/pgxpool.

We won't be implementing the full Storage interface just yet. Instead, we'll focus on the foundational task of managing database connections efficiently. This is a critical aspect of system design, as mishandling connections is a common source of performance bottlenecks and instability. You'll learn why we need a connection pool, how to choose the right Go library for the job, and how to configure it for robust performance.

Why Do We Need a Connection Pool?

Before we write any code, let's address a fundamental question. Why can't our application just open a new connection to PostgreSQL every time it needs to run a query?

Establishing a database connection is an expensive operation. It involves:

  1. Network Handshake: A TCP connection must be established between your application and the database server.
  2. TLS Negotiation: For secure connections, a TLS handshake adds several more round trips.
  3. Authentication: PostgreSQL must authenticate the client, checking credentials and permissions.
  4. Process Forking: The main PostgreSQL process often forks a new worker process to handle the connection, consuming memory and CPU.

Doing this for every single query would cripple application performance, especially under concurrent load. A connection pool solves this by creating and maintaining a cache of open connections that can be reused by multiple parts of your application.

This diagram illustrates how a connection pool works. Multiple clients request connections from the pool, which manages a limited set of open connections to the PostgreSQL database, reusing them as needed. This avoids the overhead of creating a new connection for every request.

To solidify this concept, the following article provides a concise explanation of the problem and the solution.

How to Implement Connection Pooling in Go for PostgreSQL

The article 'How to Implement Connection Pooling in Go for PostgreSQL' from OneUptime clearly explains the performance costs of creating new connections and the role a pool plays in mitigating them.

Please read the first two sections, 'On this page' and 'Understanding Connection Pooling'. Reviewing the foundational concepts will set the stage for the practical implementation.


Choosing Our Toolkit: database/sql vs. pgx

The Go standard library provides the database/sql package, which defines a generic SQL interface. While many drivers, including a pgx compatibility driver, can plug into it, we'll be using the native pgx library directly.

For a project targeting a specific database like PostgreSQL, using pgx's native interface and its connection pooler, pgxpool, offers significant advantages:

  • Performance: It uses the more efficient PostgreSQL binary protocol for many data types, bypassing the textual representation required by database/sql.
  • Features: It provides direct support for PostgreSQL-specific features like LISTEN/NOTIFY, COPY protocol, and advanced data types (JSON/JSONB, arrays, etc.).
  • Modern API: Its API was designed with context propagation from the ground up, leading to cleaner code compared to the dual Query and QueryContext methods in database/sql.

This video gives a good overview of the Go database landscape and highlights why pgx is a popular, modern choice.

Golang Microservices: PostgreSQL: sqlx, pgx and sql

In this clip from 'Golang Microservices: PostgreSQL' by Mario Carrion, he discusses the different database access methods in Go and specifically introduces pgx and pgxpool.

Starting at about nine minutes in, watch the pgxpool section. The presenter explains the benefits of using pgxpool directly, such as its explicit API and support for PostgreSQL-specific features.

This choice aligns with our goal of "absolute mastery," as we're opting for the tool that gives us the most control and performance for our target database.


Hands-On: Setting up the Connection Pool

Let's get our hands dirty. The first step is to get a PostgreSQL instance running and then write the Go code to connect to it.

Step 1: Run PostgreSQL with Docker

We'll use Docker to run a local PostgreSQL database. This is a standard industry practice that avoids polluting your local machine with different database installations.

Create a docker-compose.yml file in the root of your simple-bank project with the following content:

version: '3.8'
services:
  postgres:
    image: postgres:14-alpine
    container_name: simple-bank-db
    environment:
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=simple_bank
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Now, open a terminal in your project root and run:

docker-compose up -d

This command will download the PostgreSQL image and start a container in the background. Your database is now running and accessible on localhost:5432.

Step 2: Install pgx and Create the Connector

First, add the pgxpool library to your project's dependencies:

go get github.com/jackc/pgx/v5

Next, we'll create a new package to house our PostgreSQL logic. This keeps it separate from our MemoryStorage and follows the organizational structure we set up earlier.

  1. Create a new directory: internal/storage/postgres.
  2. Inside it, create a file named postgres.go.

Now, add the following code to internal/storage/postgres/postgres.go. This function will be responsible for creating and configuring our connection pool.

package postgres

import (
	"context"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

// Config holds the configuration for the PostgreSQL connection.
type Config struct {
	ConnString      string
	MaxConns        int32
	MinConns        int32
	MaxConnLifetime time.Duration
	MaxConnIdleTime time.Duration
}

// NewDBPool creates a new PostgreSQL connection pool.
func NewDBPool(ctx context.Context, cfg Config) (*pgxpool.Pool, error) {
	// Parse the connection string
	poolConfig, err := pgxpool.ParseConfig(cfg.ConnString)
	if err != nil {
		return nil, fmt.Errorf("failed to parse pgx config: %w", err)
	}

	// Apply pool size settings
	poolConfig.MaxConns = cfg.MaxConns
	poolConfig.MinConns = cfg.MinConns
	
	// Apply connection lifetime settings
	poolConfig.MaxConnLifetime = cfg.MaxConnLifetime
	poolConfig.MaxConnIdleTime = cfg.MaxConnIdleTime
	
	// Create the pool
	pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
	if err != nil {
		return nil, fmt.Errorf("failed to create pgxpool: %w", err)
	}
	
	// Ping the database to verify the connection
	if err := pool.Ping(ctx); err != nil {
		pool.Close() // Close the pool if ping fails
		return nil, fmt.Errorf("failed to ping database: %w", err)
	}

	return pool, nil
}

This NewDBPool function is the heart of our connection logic. It:

  1. Takes a Config struct, making its dependencies explicit.
  2. Uses pgxpool.ParseConfig to create a base configuration from a connection string.
  3. Applies our custom settings for pool size and connection lifetime.
  4. Creates the pool with pgxpool.NewWithConfig.
  5. Performs a Ping to ensure the database is reachable before returning the pool.

Step 3: Wire it Up in main.go

For now, let's modify main.go to test our new connection logic. We will replace the MemoryStorage with a call to our new NewDBPool function.

Update your cmd/main.go file:

package main

import (
	"context"
	"log"
	"simple-bank/internal/api"
	"simple-bank/internal/storage/postgres"
	"time"
)

func main() {
	// Database connection string
	// For production, this should come from environment variables!
	connString := "postgres://admin:secret@localhost:5432/simple_bank?sslmode=disable"
	
	dbConfig := postgres.Config{
		ConnString:      connString,
		MaxConns:        10,
		MinConns:        2,
		MaxConnLifetime: 1 * time.Hour,
		MaxConnIdleTime: 30 * time.Minute,
	}

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// 1. Create the database connection pool
	pool, err := postgres.NewDBPool(ctx, dbConfig)
	if err != nil {
		log.Fatalf("Failed to initialize database connection: %v", err)
	}
	defer pool.Close() // Ensure the pool is closed when main exits

	log.Println("Successfully connected to PostgreSQL!")

	// 2. We'll replace this with the real PostgresStorage in the next lesson
	// store := storage.NewPostgresStorage(pool) 

	// For now, we are just testing the connection. Let's create a placeholder store.
	// This is where our previous MemoryStorage would go. We'll connect the real one soon.
	var placeholderStore /* = storage.NewMemoryStorage() */ 

	// 3. Inject the dependency into the API server
	server := api.NewAPIServer(":8080", nil /* placeholderStore */)

	// 4. Start the application
	server.Start()
}

Note: We've set sslmode=disable for simplicity in local development. In production, you would always use require or verify-full.

Run the application:

go run cmd/main.go

You should see the following output, confirming your success:

2023/10/27 10:00:00 Successfully connected to PostgreSQL!
2023/10/27 10:00:00 API server starting on :8080

Production-Ready Configuration

Hardcoding connection strings and pool settings in main.go is fine for a quick test, but it's not suitable for real applications. Configuration should be externalized. The standard way to do this is with environment variables.

The following reading provides a robust, production-grade example of how to load a complete database configuration from environment variables and use it to create a pool.

How to Implement Connection Pooling in Go for PostgreSQL

Let's return to the OneUptime article. This section provides an excellent, self-contained example of loading all necessary database and pool settings from the environment.

Read the section 'Configuration from Environment Variables', specifically the environment configuration pattern. Pay close attention to the LoadDatabaseConfig function and the helper functions like getEnvOrDefault. This is a pattern you will use in almost every production Go service you write.

While we won't implement this full configuration loader right now, keep this pattern in mind. We will integrate a more comprehensive configuration management system using the viper library in a future lesson. For now, our hardcoded Config struct serves the same purpose of separating configuration from connection logic.


Conclusion

Excellent work! You've successfully connected our Go application to a PostgreSQL database using a properly configured, high-performance connection pool. This is a significant milestone and a core skill for any backend developer.

Key Takeaways:

  • Connection pooling is essential for performance, avoiding the high cost of establishing a new database connection for every query.
  • pgx/pgxpool is the modern, feature-rich choice for Go applications using PostgreSQL, offering better performance and a cleaner API than the standard database/sql abstractions.
  • A production connection pool requires careful tuning of parameters like MaxConns, MinConns, MaxConnLifetime, and MaxConnIdleTime.
  • Always externalize configuration (like connection strings and pool settings) from your code, typically using environment variables.

Next Up

We have an open connection to our database, but the database itself is empty. We need a way to define our schema (the tables, columns, and indexes) and manage changes to it over time in a structured, repeatable way.

In the next lesson, we will tackle this by learning how to implement database migrations using golang-migrate. You will write your first migration to create the accounts table, bringing us one step closer to a fully functional PostgreSQL-backed storage layer.