Working with SQL Databases in Go
Go's standard library provides the database/sql
package, which offers a powerful and flexible interface for working with SQL databases. This guide will help you understand how to effectively use SQL databases in your Go applications.
Core Concepts
Before diving into the code, let's understand some fundamental concepts:
Database Connections:
- A connection represents a single session with the database
- Connections are managed through a connection pool
- Each query or transaction uses one connection from the pool
- Connections should be properly closed when no longer needed
Connection Pooling:
- Go maintains a pool of database connections
- The pool can grow up to a maximum number of connections
- Idle connections are retained up to a configured limit
- Connection lifetime can be limited to prevent stale connections
Query Types:
- One-time Queries: Direct SQL statements executed once
- Prepared Statements: Pre-compiled SQL statements that can be executed multiple times
- Transactions: Groups of queries that must succeed or fail together
Database Connection
Understanding Connection Setup
The first step in working with a database is establishing a connection. In Go, this involves:
- Importing the necessary database driver
- Creating a connection string (DSN)
- Opening the connection pool
- Verifying the connection
Here's how to set up a basic connection:
package main
import (
"database/sql"
"log"
_ "github.com/lib/pq" // PostgreSQL driver
)
func main() {
// Connect to database
db, err := sql.Open("postgres", "postgres://username:password@localhost/dbname?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Test connection
if err := db.Ping(); err != nil {
log.Fatal(err)
}
}
Connection Configuration Best Practices
When setting up database connections in production applications, you should:
- Use configuration structures for better organization
- Set appropriate connection pool limits
- Configure connection timeouts
- Handle SSL/TLS settings
- Use environment variables for sensitive information
Here's a more robust connection setup:
type DBConfig struct {
Driver string
Host string
Port int
User string
Password string
DBName string
SSLMode string
}
func NewDB(config DBConfig) (*sql.DB, error) {
dsn := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
config.Host, config.Port, config.User,
config.Password, config.DBName, config.SSLMode,
)
db, err := sql.Open(config.Driver, dsn)
if err != nil {
return nil, err
}
// Configure connection pool
db.SetMaxOpenConns(25) // Maximum number of open connections
db.SetMaxIdleConns(5) // Maximum number of idle connections
db.SetConnMaxLifetime(5 * time.Minute) // Maximum connection lifetime
return db, nil
}
Executing Queries
Understanding Query Types
Go provides several ways to execute queries, each suited for different scenarios:
- QueryRow: For queries expecting a single row result
- Query: For queries returning multiple rows
- Exec: For queries that don't return rows (INSERT, UPDATE, DELETE)
Let's look at each type in detail:
Single-Row Queries
When retrieving a single row, use QueryRow
. This is common for lookups by ID or unique fields:
func GetUser(db *sql.DB, id int) (*User, error) {
var user User
query := "SELECT id, name, email FROM users WHERE id = $1"
err := db.QueryRow(query, id).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user not found: %d", id)
}
return nil, err
}
return &user, nil
}
Key points about single-row queries:
- Use parameter placeholders ($1, $2, etc.) to prevent SQL injection
- Handle the special case of no rows found (sql.ErrNoRows)
- Use Scan to map column values to Go variables
Multiple-Row Queries
For queries that return multiple rows, use Query
. Important considerations:
- Always close the rows object
- Check for errors after iteration
- Handle column scanning efficiently
func GetUsers(db *sql.DB) ([]User, error) {
rows, err := db.Query("SELECT id, name, email FROM users")
if err != nil {
return nil, err
}
defer rows.Close() // Important: always close rows
var users []User
for rows.Next() {
var user User
if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil {
return nil, err
}
users = append(users, user)
}
// Important: check for errors during iteration
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}
Prepared Statements
Understanding Prepared Statements
Prepared statements offer several advantages:
- Better performance for repeated queries
- Protection against SQL injection
- Cleaner code organization
However, they also come with considerations:
- Statements must be closed when no longer needed
- Each statement maintains its own database connection
- Statements cannot be used after the database connection is closed
Here's how to effectively use prepared statements:
type UserRepository struct {
db *sql.DB
insertStmt *sql.Stmt
updateStmt *sql.Stmt
}
func NewUserRepository(db *sql.DB) (*UserRepository, error) {
insertStmt, err := db.Prepare(`
INSERT INTO users (name, email)
VALUES ($1, $2)
RETURNING id
`)
if err != nil {
return nil, err
}
updateStmt, err := db.Prepare(`
UPDATE users
SET name = $1, email = $2
WHERE id = $3
`)
if err != nil {
insertStmt.Close()
return nil, err
}
return &UserRepository{
db: db,
insertStmt: insertStmt,
updateStmt: updateStmt,
}, nil
}
Best Practices and Common Pitfalls
Do's:
- Always use parameter placeholders
- Close resources (rows, statements)
- Handle errors appropriately
- Use transactions for related operations
- Set appropriate timeouts
- Monitor connection pool metrics
Don'ts:
- Don't concatenate strings to build queries
- Don't ignore errors during row iteration
- Don't forget to close rows
- Don't share statements between goroutines
- Don't keep transactions open for long periods
Next Steps
- Learn about Transactions
- Explore Query Building
- Study Connection Pooling