Irbisa · Cheatsheet

Databases & SQL in Go

database/sql pooling, transactions, NULLs, pgx, migrations

Middle Developer · 24 questions

  1. 01

    What is the difference between WHERE and HAVING, and between INNER JOIN and LEFT JOIN?

    WHERE filters rows before grouping; HAVING filters groups after the aggregates are computed.

    -- customers with more than 5 paid orders this yearSELECT c.id, c.email, count(*) AS paid_ordersFROM customers cJOIN orders o ON o.customer_id = c.id   -- INNER: customers with no orders drop outWHERE o.status = 'paid'                  -- row filter, runs before GROUP BY  AND o.created_at >= '2026-01-01'…
  2. 02

    What does sql.Open actually do, and why does it return no error when the database is unreachable?

    sql.Open never touches the network. It builds a *sql.DB (a pool that starts with zero connections), and the first connection is opened by your first query or Ping.

    func openDB(ctx context.Context, dsn string) (*sql.DB, error) {	// No network traffic here. An error means an unknown driver or a	// malformed DSN, never "the database is down".	db, err := sql.Open("pgx", dsn)	if err != nil {		return nil, fmt.Errorf("open: %w", err)…
  3. 03

    When do you use Exec, Query and QueryRow, and what breaks if you run a DELETE through Query?

    Exec is for statements that return no rows, Query for a result set and QueryRow for at most one row.

    // wrong: Query on a write. The rows are never closed, so the connection never// returns to the pool. The DELETE itself succeeds, which hides the leak.rows, err := db.QueryContext(ctx, "DELETE FROM sessions WHERE expires_at < now()")if err != nil {	return err}…
  4. 04

    Why does a missing row arrive as sql.ErrNoRows from Scan rather than from QueryRow itself, and where should that error be handled?

    QueryRow returns no error itself, so it parks one and Scan returns the sentinel sql.ErrNoRows. Translate it once, at the repository boundary, into your own ErrUserNotFound with errors.Is.

    var ErrUserNotFound = errors.New("user not found") func (r *Repo) User(ctx context.Context, id int64) (User, error) {	var u User	err := r.db.QueryRowContext(ctx,		"SELECT id, email, created_at FROM users WHERE id = $1", id,…
  5. 05

    A column can be NULL. What can you scan it into, how do you write NULL back, and what does COALESCE change?

    A Go string has no room for NULL, so Scan fails with converting NULL to string is unsupported.

    func profile(ctx context.Context, db *sql.DB, id int64) error {	// wrong: var nickname string	// sql: Scan error on column index 0, name "nickname":	// converting NULL to string is unsupported 	var nickname *string             // nil means NULL; marshals to JSON null…
  6. 06

    This export sometimes writes fewer rows than the table holds, and no error is ever logged. What is missing?

    func exportUsers(ctx context.Context, db *sql.DB, w io.Writer) error {	rows, err := db.QueryContext(ctx, "SELECT id, email FROM users ORDER BY id")	if err != nil {		return err	}	defer rows.Close()	for rows.Next() {		var id int64		var email string		if err := rows.Scan(&id, &email); err != nil {			return err		}		fmt.Fprintf(w, "%d,%s\n", id, email)	}	return nil}

    rows.Err() after the loop is missing. Next returns false both when the rows run out and when the iteration breaks, so a connection dropped at row 4,000 looks exactly like a clean finish.

  7. 07

    What does ACID mean, and which anomalies do Read Committed, Repeatable Read and Serializable each allow?

    ACID: a transaction is all or nothing, keeps constraints true, is isolated from concurrent ones and survives a crash once committed.

  8. 08

    What index types does PostgreSQL offer, when does a composite index help a query, and how do you read EXPLAIN ANALYZE?

    An index lets the database find rows without reading the whole table; B-tree is the default for equality, ranges and ORDER BY.

    CREATE INDEX CONCURRENTLY orders_user_created_idx    ON orders (user_id, created_at DESC); -- uses it: equality on the leading column, range and order on the secondEXPLAIN (ANALYZE, BUFFERS)SELECT id, total FROM orders…
  9. 09

    This function places an order inside a transaction and passes its tests. Which two bugs will show up in production?

    func (s *Store) PlaceOrder(ctx context.Context, o Order) error {	tx, err := s.db.BeginTx(ctx, nil)	if err != nil {		return err	}	if _, err := tx.ExecContext(ctx, insertOrderSQL, o.ID, o.UserID); err != nil {		return err	}	if _, err := tx.ExecContext(ctx, reserveStockSQL, o.SKU, o.Qty); err != nil {		tx.Rollback()		return err	}	tx.Commit()	return nil}

    The early return after the first ExecContext never rolls back, so the transaction keeps its pool connection and its row locks; and the error from tx.Commit() is dropped, so a failed commit is reported as a placed order.

  10. 10

    This search function is open to SQL injection. A placeholder fixes one of the two interpolations. What do you do with the other?

    func (r *Repo) Search(ctx context.Context, q, sortBy string) (*sql.Rows, error) {	query := fmt.Sprintf(		"SELECT id, name FROM docs WHERE name ILIKE '%%%s%%' ORDER BY %s LIMIT 50",		q, sortBy)	return r.db.QueryContext(ctx, query)}

    Values go in placeholders; identifiers cannot. $1 makes the search text safe, but a column name in ORDER BY is safe only if you pick it from a whitelist you wrote yourself.

  11. 11

    How do SetMaxOpenConns, SetMaxIdleConns and SetConnMaxLifetime work, and how do you size the pool for a busy service?

    SetMaxOpenConns caps the connections (unlimited by default), SetMaxIdleConns keeps some open between bursts (default 2), and SetConnMaxLifetime recycles them.

    func configurePool(db *sql.DB) {	// Per pod. Postgres max_connections is shared by every pod, every cron	// job and whatever a human has open in psql.	db.SetMaxOpenConns(20)	db.SetMaxIdleConns(20) // equal, or the pool re-dials under load	db.SetConnMaxLifetime(30 * time.Minute) // recycle by age: DNS/failover changes, proxy max age…
  12. 12

    What happens to a running query when the request's context is cancelled, and why do you still need statement_timeout?

    With QueryContext, cancellation makes database/sql stop waiting and the driver ask Postgres to cancel the query; with plain Query nothing is wired up and the query runs on.

    func (r *Repo) TopSellers(ctx context.Context, since time.Time) ([]Row, error) {	// A slice of the request's budget: one slow report cannot hold a pool	// connection for a minute, and a client disconnect cancels it too.	ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)	defer cancel()…
  13. 13

    How do you run one transaction across several repository methods without leaking *sql.Tx into the service layer?

    Make repository functions accept a small interface that both *sql.DB and *sql.Tx satisfy, and give the service one InTx(ctx, func(q DBTX) error) that begins, commits and rolls back.

    // Implemented by *sql.DB, *sql.Tx and *sql.Conn.type DBTX interface {	ExecContext(context.Context, string, ...any) (sql.Result, error)	QueryContext(context.Context, string, ...any) (*sql.Rows, error)	QueryRowContext(context.Context, string, ...any) *sql.Row}…
  14. 14

    What is the difference between database/sql, pgx, sqlx, sqlc and GORM, and how do you choose one for a new service?

    database/sql is the standard pool-and-scan API; pgx is the Postgres driver with a richer native API; sqlx trims Scan boilerplate; sqlc generates typed Go from SQL you write; GORM writes the SQL for you.

    type Order struct {	ID     int64 `db:"id"`	UserID int64 `db:"user_id"`	Total  int64 `db:"total"`}…
  15. 15

    What is the N+1 query problem, and how do you fix it in Go without an ORM?

    N+1 is one query for a list, then one more query per item inside a loop.

    // Two queries total, however many orders came back.func (r *Repo) OrdersWithItems(ctx context.Context, userID int64) ([]Order, error) {	orders, err := r.orders(ctx, userID)	if err != nil || len(orders) == 0 {		return orders, err	}…
  16. 16

    How do you bulk-insert 200,000 rows from Go efficiently, and what limits a multi-row INSERT?

    One INSERT per row spends its time on 200,000 round trips, not on database work.

    // pgx native: one statement, one stream, no per-row protocol overheadfunc loadEvents(ctx context.Context, pool *pgxpool.Pool, events []Event) (int64, error) {	return pool.CopyFrom(ctx,		pgx.Identifier{"events"},		[]string{"user_id", "kind", "at"},		pgx.CopyFromSlice(len(events), func(i int) ([]any, error) {…
  17. 17

    What is the difference between optimistic and pessimistic locking, and how do you build a job queue on Postgres with SKIP LOCKED?

    Pessimistic locking takes the lock up front with `SELECT ...

    // Optimistic: no lock held; the version check catches a concurrent writer.func (r *Repo) Rename(ctx context.Context, id, version int64, title string) error {	res, err := r.db.ExecContext(ctx, `		UPDATE docs SET title = $1, version = version + 1		WHERE id = $2 AND version = $3`, title, id, version)	if err != nil {…
  18. 18

    Two transfers between the same accounts fail with 40P01 deadlock detected. Why does it happen, and how do you prevent it instead of just retrying?

    Each transaction locks the rows in transfer order, so A→B and B→A each hold one lock and wait for the other; Postgres finds the cycle after deadlock_timeout (1 s) and aborts one.

    // wrong: lock order follows the transfer direction, so A->B and B->A deadlock// SELECT ... WHERE id = $from FOR UPDATE; then SELECT ... WHERE id = $to FOR UPDATE // right: one deterministic order for every transaction in the systemfunc transfer(ctx context.Context, tx *sql.Tx, from, to, amount int64) error {	ids := []int64{from, to}…
  19. 19

    Find the bug: under load CreateOrder stalls until the request times out, while the database itself is nearly idle.

    func (s *Store) CreateOrder(ctx context.Context, o Order) error {	tx, err := s.db.BeginTx(ctx, nil)	if err != nil {		return err	}	defer tx.Rollback()	if _, err := tx.ExecContext(ctx, insertOrderSQL, o.ID, o.UserID, o.Total); err != nil {		return err	}	if _, err := s.db.ExecContext(ctx, insertAuditSQL, o.ID, "order_created"); err != nil {		return err	}	return tx.Commit()}

    The audit insert goes through s.db, not tx, so it takes a second connection from the pool and commits on its own.

  20. 20

    What does db.Prepare mean when a connection pool sits underneath, and why can a shared *sql.Stmt break behind PgBouncer?

    A prepared statement lives on one server connection.

    // wrong: a process-wide Stmt over a pool: re-prepared on every connection it// touches, and broken behind a transaction-mode pooler.// var getUser *sql.Stmt // prepared in main(), used from every handler // right: just pass the args; pgx caches the statement per connection itselffunc (r *Repo) User(ctx context.Context, id int64) (User, error) {…
  21. 21

    How does MVCC work in PostgreSQL, and why can one long-running transaction bloat tables for everyone?

    Postgres never updates a row in place: UPDATE writes a new row version and marks the old one dead, and each transaction sees the versions its snapshot allows.

  22. 22

    How do you retry a transaction that fails with SQLSTATE 40001 under Serializable, and what must never be inside the retry loop?

    Retry the whole transaction, not the failed statement: a serialization failure rolls everything back, so you re-run the function from BeginTx and re-read every value.

    func (r *Repo) InSerializableTx(ctx context.Context, fn func(DBTX) error) error {	const maxAttempts = 4	var err error	for attempt := 1; attempt <= maxAttempts; attempt++ {		err = r.runOnce(ctx, fn) // the whole transaction, re-read from scratch		if err == nil || !isRetryable(err) {…
  23. 23

    How do you change a production schema with no downtime (say, renaming a column), and where do goose or golang-migrate fit in?

    Expand, migrate, contract: during a rolling deploy old and new code share one database, so every schema step must work with both.

    // Release 1 code: still reads name, writes both, so old pods keep working.func (r *Repo) SetName(ctx context.Context, id int64, name string) error {	_, err := r.db.ExecContext(ctx,		"UPDATE users SET name = $1, full_name = $1 WHERE id = $2", name, id)	return err}…
  24. 24

    What is the difference between replication and sharding, and when do you choose synchronous over asynchronous replication?

    Replication keeps full copies of the same data on several servers, for failover and read scaling; sharding splits the data by a key so each server holds a part, which scales writes.