The database/sql package provides a generic interface for SQL databases. Think of database/sql as a universal remote control: it provides the same buttons (Query, Exec, Begin) regardless of which TV (database) you are talking to. The driver is the infrared translator that converts your button presses into the specific protocol your database understands. The package manages a connection pool, handles reconnection, and provides a consistent API regardless of which database you use. You need a driver for your specific database — the driver registers itself via an init function (this is one of the good uses of init and blank imports).
Critical Pitfall: sql.Open does not actually establish a connection to the database. It only validates the driver name and prepares the connection pool. The first actual connection happens when you execute a query. Always call db.Ping() or db.PingContext() after sql.Open to verify connectivity:
db, err := sql.Open("postgres", connStr)if err != nil { return err // Driver name is wrong, or DSN is malformed}if err := db.Ping(); err != nil { return err // Actually cannot connect to the database}
import ( "database/sql" _ "github.com/lib/pq" // PostgreSQL // _ "github.com/go-sql-driver/mysql" // MySQL // _ "github.com/mattn/go-sqlite3" // SQLite)func main() { // Connection string format varies by driver connStr := "host=localhost port=5432 user=postgres password=secret dbname=myapp sslmode=disable" db, err := sql.Open("postgres", connStr) if err != nil { log.Fatal(err) } defer db.Close() // Verify connection if err := db.Ping(); err != nil { log.Fatal(err) } // Configure connection pool -- these settings are critical for production! // Without them, defaults can either exhaust database connections or be too conservative. db.SetMaxOpenConns(25) // Max open connections (prevents overwhelming the DB) db.SetMaxIdleConns(5) // Max idle connections (keeps warm connections in the pool) db.SetConnMaxLifetime(5 * time.Minute) // Max connection lifetime (prevents stale connections) db.SetConnMaxIdleTime(1 * time.Minute) // Max idle time (frees unused connections)}
Connection Pool Sizing: A common rule of thumb is to set MaxOpenConns to 2-3x the number of CPU cores for your database server, but never exceed the database’s own max connections limit. For cloud databases (RDS, Cloud SQL), check your instance’s connection limit and leave headroom for monitoring and admin connections. Setting MaxOpenConns too high can actually decrease performance due to connection thrashing.
Transactions are like an “all-or-nothing” switch: either every operation within the transaction succeeds and is committed together, or any failure rolls everything back as if nothing happened. In Go, the pattern is to defer tx.Rollback() immediately after beginning the transaction. If Commit() is called successfully, the deferred Rollback() becomes a no-op. If any error occurs, the deferred Rollback() cleans up.
Pitfall — Using db.Query Inside a Transaction: If you call r.db.QueryContext() instead of tx.QueryContext() inside a transaction block, the query runs on a different connection outside the transaction. This is a subtle bug that can cause phantom reads, missed updates, and data inconsistency. Always use tx.QueryContext, tx.ExecContext, and tx.QueryRowContext within a transaction — never the db.* methods.
Pitfall — Long-Running Transactions: Holding a transaction open for too long (e.g., waiting for an external API call within a transaction) blocks the database row or table and can cause timeouts for other queries. Keep transactions short: do all non-database work before beginning the transaction, then execute only the SQL statements inside it.
type User struct { ID int64 Name string Email *string // nil if NULL Phone *string // nil if NULL}func (r *UserRepository) GetByID(ctx context.Context, id int64) (*User, error) { query := `SELECT id, name, email, phone FROM users WHERE id = $1` var user User err := r.db.QueryRowContext(ctx, query, id). Scan(&user.ID, &user.Name, &user.Email, &user.Phone) return &user, err}
import "github.com/jmoiron/sqlx"type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` CreatedAt time.Time `db:"created_at"`}func main() { db, err := sqlx.Connect("postgres", connectionString) if err != nil { log.Fatal(err) }}// Get single rowvar user Usererr := db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = $1", 1)// Get multiple rowsvar users []Usererr := db.SelectContext(ctx, &users, "SELECT * FROM users WHERE age > $1", 18)// Named queriesuser := User{Name: "John", Email: "john@example.com"}result, err := db.NamedExecContext(ctx, "INSERT INTO users (name, email) VALUES (:name, :email)", user)// Named query with IN clausequery, args, err := sqlx.In("SELECT * FROM users WHERE id IN (?)", []int{1, 2, 3})query = db.Rebind(query)var users []Usererr = db.SelectContext(ctx, &users, query, args...)
Explain how database/sql connection pooling works in Go. What do MaxOpenConns, MaxIdleConns, and ConnMaxLifetime actually do, and what happens if you misconfigure them?
Strong Answer:
database/sql maintains a pool of database connections internally. When you call QueryContext or ExecContext, it grabs a connection from the pool (or opens a new one), executes the query, and returns the connection to the pool.
MaxOpenConns limits the total number of connections (both in-use and idle). If all connections are in use and a new query arrives, it blocks until one is returned. Set this too high, and you overwhelm the database server. Set it too low, and your application blocks during traffic spikes.
MaxIdleConns controls how many connections stay open in the pool when not in use. Idle connections avoid TCP handshake overhead. Set too high, and you waste database resources. Set too low, and you repeatedly open/close connections.
ConnMaxLifetime forces connections to be closed and replaced after a duration. This prevents stale connections from database firewalls, DNS changes, and connection-level state accumulation. A typical value is 5 minutes.
Misconfiguration example: leaving defaults (MaxOpenConns = 0, meaning unlimited) in a service handling 10,000 concurrent requests. The pool opens 10,000 connections, PostgreSQL rejects them (default max is 100), and you get a flood of “too many connections” errors.
Follow-up: You forgot to call rows.Close() after a QueryContext. What happens?The connection is never returned to the pool. It is “in use” forever. As more queries leak connections, the pool reaches MaxOpenConns, and subsequent queries block indefinitely — classic connection pool exhaustion. The fix is always defer rows.Close() immediately after QueryContext.
Walk me through how you would implement the repository pattern for a Go service, and how does this help with testing?
Strong Answer:
Define a repository interface at the service layer: type UserRepository interface { GetByID(ctx context.Context, id int64) (*User, error); Create(ctx context.Context, user *User) error }.
The concrete implementation uses actual SQL in a postgres package. The service constructor takes the interface: func NewUserService(repo UserRepository) *UserService.
For testing, a mock implements the same interface with in-memory operations. Integration tests inject the real PostgreSQL implementation against a test database.
Key design decisions: the interface uses domain types (*User), not database types (sql.NullString). Error types are domain-specific (ErrNotFound). Context propagates as the first parameter.
The pattern also makes caching transparent: a CachedUserRepo wraps a UserRepository, checks Redis first, falls back to the wrapped repo. Same interface, invisible to the service.
Follow-up: sql.Open does not actually connect to the database. Why was it designed this way?sql.Open creates the pool configuration but opens no TCP connections. The pool is lazy, creating connections on demand. This allows the application to start before the database is ready (common in container orchestration). To verify connectivity, call db.PingContext(ctx) after sql.Open.
Your service needs to insert an order with 50 line items atomically. Show me the transaction pattern and explain the `defer tx.Rollback()` idiom.
Strong Answer:
Start with tx, err := db.BeginTx(ctx, nil). Then defer tx.Rollback() immediately — rollback on an already-committed transaction is a no-op, so this is safe. Execute queries with tx.ExecContext. If any step fails, return the error (deferred rollback fires). On success, call tx.Commit().
This idiom handles all failure paths: early returns, panics, and context cancellation. You never need manual rollback in error branches.
If context is cancelled mid-transaction, the current query fails. The deferred rollback sends ROLLBACK to the database. If the connection was lost, the database server rolls back after its idle timeout.
For 50 line items: use batch inserts rather than 50 individual INSERTs. This reduces round trips from 50 to 1.
Edge case: if tx.Commit() fails (network error), the transaction might or might not have committed server-side. For critical operations, use idempotency keys.
Follow-up: What is the difference between QueryContext and ExecContext?QueryContext returns *sql.Rows for SELECT queries. ExecContext returns sql.Result (with LastInsertId() and RowsAffected()) for INSERT, UPDATE, DELETE. Using QueryContext for an INSERT works but you must still call rows.Close() or you leak a connection. Using ExecContext for a SELECT discards the data. Rule: QueryContext for reading, ExecContext for writing.