Go Database Patterns with sqlx and pgx
Master Go database patterns using sqlx and pgx for PostgreSQL including connection pooling, transactions, batch operations, and repository design.
What you'll learn
- ✓Use sqlx for ergonomic database queries
- ✓Leverage pgx for high-performance PostgreSQL access
- ✓Implement the repository pattern for clean data access
- ✓Handle transactions, batch operations, and migrations
Prerequisites
- •Go basics and error handling
- •SQL fundamentals
- •PostgreSQL basics
Go’s standard database/sql package is solid but verbose. You spend a lot of time scanning columns into struct fields manually. Two libraries solve this elegantly: sqlx extends database/sql with struct scanning and named parameters, while pgx is a pure Go PostgreSQL driver that gives you maximum performance and PostgreSQL-specific features. This guide shows you how to use both effectively.
Getting Started with sqlx
Install sqlx and a PostgreSQL driver.
go get github.com/jmoiron/sqlx
go get github.com/lib/pq
Connect to the database and define your models with db struct tags.
package main
import (
"fmt"
"log"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type User struct {
ID int `db:"id"`
Email string `db:"email"`
Name string `db:"name"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type Post struct {
ID int `db:"id"`
UserID int `db:"user_id"`
Title string `db:"title"`
Body string `db:"body"`
PublishedAt *time.Time `db:"published_at"`
CreatedAt time.Time `db:"created_at"`
}
func main() {
db, err := sqlx.Connect("postgres",
"host=localhost port=5432 user=app dbname=myapp sslmode=disable")
if err != nil {
log.Fatalf("failed to connect: %v", err)
}
defer db.Close()
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
fmt.Println("Connected to database")
}
Querying with sqlx
sqlx eliminates manual column scanning. Use Get for single rows and Select for multiple rows.
// Get a single user
var user User
err := db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = $1", userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get user: %w", err)
}
// Get multiple users
var users []User
err := db.SelectContext(ctx, &users,
"SELECT * FROM users WHERE created_at > $1 ORDER BY name", since)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
// Named queries: use struct fields as parameters
user := User{Name: "Alice", Email: "alice@example.com"}
result, err := db.NamedExecContext(ctx,
`INSERT INTO users (name, email, created_at, updated_at)
VALUES (:name, :email, NOW(), NOW())`, user)
if err != nil {
return fmt.Errorf("insert user: %w", err)
}
The Repository Pattern with sqlx
Wrap database access behind a repository interface for clean separation.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
"myapp/internal/domain"
)
type UserRepository struct {
db *sqlx.DB
}
func NewUserRepository(db *sqlx.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
query := `
INSERT INTO users (email, name, created_at, updated_at)
VALUES ($1, $2, NOW(), NOW())
RETURNING id, created_at, updated_at`
return r.db.QueryRowxContext(ctx, query, user.Email, user.Name).
Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
}
func (r *UserRepository) GetByID(ctx context.Context, id int) (*domain.User, error) {
var user domain.User
err := r.db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = $1", id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get user by id: %w", err)
}
return &user, nil
}
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
var user domain.User
err := r.db.GetContext(ctx, &user, "SELECT * FROM users WHERE email = $1", email)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get user by email: %w", err)
}
return &user, nil
}
func (r *UserRepository) Update(ctx context.Context, user *domain.User) error {
query := `
UPDATE users SET name = $1, email = $2, updated_at = NOW()
WHERE id = $3
RETURNING updated_at`
err := r.db.QueryRowxContext(ctx, query, user.Name, user.Email, user.ID).
Scan(&user.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrNotFound
}
return fmt.Errorf("update user: %w", err)
}
return nil
}
func (r *UserRepository) List(ctx context.Context, limit, offset int) ([]domain.User, error) {
var users []domain.User
err := r.db.SelectContext(ctx, &users,
"SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
limit, offset)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
return users, nil
}
func (r *UserRepository) Delete(ctx context.Context, id int) error {
result, err := r.db.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return domain.ErrNotFound
}
return nil
}
Transactions with sqlx
For operations that must succeed or fail together, use transactions.
func (r *OrderRepository) CreateOrder(ctx context.Context, order *domain.Order) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
// Defer a rollback. If the transaction was committed, this is a no-op.
defer tx.Rollback()
// Insert the order
err = tx.QueryRowxContext(ctx,
`INSERT INTO orders (user_id, total, status, created_at)
VALUES ($1, $2, $3, NOW()) RETURNING id, created_at`,
order.UserID, order.Total, "pending").
Scan(&order.ID, &order.CreatedAt)
if err != nil {
return fmt.Errorf("insert order: %w", err)
}
// Insert order items
for i := range order.Items {
item := &order.Items[i]
_, err := tx.ExecContext(ctx,
`INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES ($1, $2, $3, $4)`,
order.ID, item.ProductID, item.Quantity, item.Price)
if err != nil {
return fmt.Errorf("insert order item: %w", err)
}
}
// Deduct inventory
for _, item := range order.Items {
result, err := tx.ExecContext(ctx,
`UPDATE products SET stock = stock - $1
WHERE id = $2 AND stock >= $1`,
item.Quantity, item.ProductID)
if err != nil {
return fmt.Errorf("deduct stock: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("insufficient stock for product %d", item.ProductID)
}
}
return tx.Commit()
}
Using pgx Directly
pgx is a PostgreSQL-specific driver that offers better performance and PostgreSQL-specific features like COPY, LISTEN/NOTIFY, and custom types. Use pgx when you need these features or want maximum performance.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("unable to create pool: %v", err)
}
defer pool.Close()
// Simple query
var name string
var email string
err = pool.QueryRow(ctx,
"SELECT name, email FROM users WHERE id = $1", 1).
Scan(&name, &email)
if err != nil {
log.Fatalf("query failed: %v", err)
}
fmt.Printf("User: %s <%s>\n", name, email)
}
Batch Operations with pgx
pgx supports batch queries that send multiple statements in a single round trip.
func (r *ProductRepo) CreateBatch(ctx context.Context, products []Product) error {
batch := &pgx.Batch{}
for _, p := range products {
batch.Queue(
"INSERT INTO products (name, price, stock) VALUES ($1, $2, $3)",
p.Name, p.Price, p.Stock,
)
}
results := r.pool.SendBatch(ctx, batch)
defer results.Close()
for range products {
_, err := results.Exec()
if err != nil {
return fmt.Errorf("batch insert: %w", err)
}
}
return nil
}
COPY Protocol for Bulk Inserts
For inserting thousands of rows, the PostgreSQL COPY protocol is much faster than individual INSERT statements. pgx supports this natively.
func (r *EventRepo) BulkInsert(ctx context.Context, events []Event) (int64, error) {
rows := make([][]any, len(events))
for i, e := range events {
rows[i] = []any{e.Type, e.Payload, e.CreatedAt}
}
copyCount, err := r.pool.CopyFrom(
ctx,
pgx.Identifier{"events"},
[]string{"type", "payload", "created_at"},
pgx.CopyFromRows(rows),
)
if err != nil {
return 0, fmt.Errorf("copy from: %w", err)
}
return copyCount, nil
}
LISTEN/NOTIFY for Real-Time Updates
pgx supports PostgreSQL’s LISTEN/NOTIFY for real-time event streaming between your application and the database.
func listenForChanges(ctx context.Context, pool *pgxpool.Pool) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("acquire connection: %w", err)
}
defer conn.Release()
_, err = conn.Exec(ctx, "LISTEN order_events")
if err != nil {
return fmt.Errorf("listen: %w", err)
}
for {
notification, err := conn.Conn().WaitForNotification(ctx)
if err != nil {
return fmt.Errorf("wait for notification: %w", err)
}
fmt.Printf("Channel: %s, Payload: %s\n",
notification.Channel, notification.Payload)
}
}
Connection Pool Configuration
Both sqlx and pgx support connection pooling. Proper configuration prevents connection exhaustion.
// sqlx pool configuration
db.SetMaxOpenConns(25) // Max connections in pool
db.SetMaxIdleConns(5) // Max idle connections
db.SetConnMaxLifetime(5 * time.Minute) // Max connection age
db.SetConnMaxIdleTime(1 * time.Minute) // Max idle time
// pgx pool configuration
config, _ := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = 5 * time.Minute
config.MaxConnIdleTime = 1 * time.Minute
config.HealthCheckPeriod = 30 * time.Second
pool, _ := pgxpool.NewWithConfig(ctx, config)
A good starting point is MaxConns equal to 2 * number_of_cpu_cores + number_of_disks on your database server. For cloud databases, check the provider’s connection limits.
Handling NULL Values
Database NULLs require pointer types or sql.Null types in Go.
// Using pointers (works with both sqlx and pgx)
type Article struct {
ID int `db:"id"`
Title string `db:"title"`
PublishedAt *time.Time `db:"published_at"` // nil when NULL
DeletedAt *time.Time `db:"deleted_at"`
}
// Using sql.Null types
type Article struct {
ID int `db:"id"`
Title string `db:"title"`
PublishedAt sql.NullTime `db:"published_at"`
ViewCount sql.NullInt64 `db:"view_count"`
Subtitle sql.NullString `db:"subtitle"`
}
Pointer types are simpler. Use sql.Null* types when you need to distinguish between a zero value and NULL.
Testing Database Code
Use a test database and transactions that roll back after each test.
func setupTestDB(t *testing.T) *sqlx.DB {
t.Helper()
db, err := sqlx.Connect("postgres",
"host=localhost dbname=myapp_test sslmode=disable")
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestUserRepository_Create(t *testing.T) {
db := setupTestDB(t)
// Start a transaction that we will roll back
tx, err := db.Beginx()
if err != nil {
t.Fatalf("begin: %v", err)
}
t.Cleanup(func() { tx.Rollback() })
repo := NewUserRepository(tx) // Accept sqlx.ExtContext interface
user := &domain.User{
Name: "Test User",
Email: "test@example.com",
}
err = repo.Create(context.Background(), user)
if err != nil {
t.Fatalf("create: %v", err)
}
if user.ID == 0 {
t.Error("expected ID to be set")
}
// Verify the user exists within the transaction
got, err := repo.GetByID(context.Background(), user.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "test@example.com" {
t.Errorf("email = %q, want %q", got.Email, "test@example.com")
}
}
Wrapping Up
sqlx and pgx solve different problems in Go database access. sqlx extends database/sql with convenient struct scanning, named parameters, and In clause expansion, working with any SQL database. pgx gives you PostgreSQL-specific power: COPY protocol for bulk loads, LISTEN/NOTIFY for real-time updates, and batch queries for reduced round trips. Use sqlx when you want portability and simplicity. Use pgx when you are committed to PostgreSQL and need its advanced features. In both cases, wrap your database access behind repository interfaces, handle transactions properly, and configure your connection pool to match your workload.
Related articles
- Go Go database/sql Tutorial
Use Go's standard database/sql package the right way: drivers, connection pools, prepared statements, transactions, context cancellation, and avoiding the classic Rows.Close leak.
- SQL MongoDB vs PostgreSQL: When to Use Each Database
Compare MongoDB and PostgreSQL across data modeling, performance, scalability, and use cases. Choose the right database for your project.
- SQL PostgreSQL vs MySQL vs SQLite: Choosing the Right Database
Compare PostgreSQL, MySQL, and SQLite on features, performance, scalability, and use cases. Find the best relational database for your project in 2026.
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.