1. go
  2. /database
  3. /query-building

Building SQL Queries in Go

Building SQL queries safely and efficiently is crucial for database applications. This guide covers best practices and patterns for query building in Go.

Basic Query Building

Static Queries

package main

import (
    "database/sql"
    "time"
)

type User struct {
    ID        int64
    Name      string
    Email     string
    CreatedAt time.Time
}

func GetUserByID(db *sql.DB, id int64) (*User, error) {
    query := `
        SELECT id, name, email, created_at
        FROM users
        WHERE id = $1
    `
    
    var user User
    err := db.QueryRow(query, id).Scan(
        &user.ID,
        &user.Name,
        &user.Email,
        &user.CreatedAt,
    )
    if err != nil {
        return nil, err
    }
    
    return &user, nil
}

func CreateUser(db *sql.DB, user *User) error {
    query := `
        INSERT INTO users (name, email, created_at)
        VALUES ($1, $2, $3)
        RETURNING id
    `
    
    return db.QueryRow(
        query,
        user.Name,
        user.Email,
        time.Now(),
    ).Scan(&user.ID)
}

Dynamic Queries

type UserFilter struct {
    Name      *string
    Email     *string
    CreatedAt *time.Time
    SortBy    string
    SortDesc  bool
    Limit     int
    Offset    int
}

func BuildUserQuery(filter UserFilter) (string, []interface{}) {
    query := "SELECT id, name, email, created_at FROM users WHERE 1=1"
    var args []interface{}
    argPos := 1
    
    if filter.Name != nil {
        query += fmt.Sprintf(" AND name LIKE $%d", argPos)
        args = append(args, "%"+*filter.Name+"%")
        argPos++
    }
    
    if filter.Email != nil {
        query += fmt.Sprintf(" AND email = $%d", argPos)
        args = append(args, *filter.Email)
        argPos++
    }
    
    if filter.CreatedAt != nil {
        query += fmt.Sprintf(" AND created_at >= $%d", argPos)
        args = append(args, *filter.CreatedAt)
        argPos++
    }
    
    if filter.SortBy != "" {
        query += " ORDER BY " + filter.SortBy
        if filter.SortDesc {
            query += " DESC"
        }
    }
    
    if filter.Limit > 0 {
        query += fmt.Sprintf(" LIMIT $%d", argPos)
        args = append(args, filter.Limit)
        argPos++
    }
    
    if filter.Offset > 0 {
        query += fmt.Sprintf(" OFFSET $%d", argPos)
        args = append(args, filter.Offset)
    }
    
    return query, args
}

Query Building Patterns

1. Query Builder

type QueryBuilder struct {
    sql  strings.Builder
    args []interface{}
    pos  int
}

func NewQueryBuilder() *QueryBuilder {
    return &QueryBuilder{pos: 1}
}

func (qb *QueryBuilder) WriteString(s string) {
    qb.sql.WriteString(s)
}

func (qb *QueryBuilder) AddArg(arg interface{}) string {
    qb.args = append(qb.args, arg)
    placeholder := fmt.Sprintf("$%d", qb.pos)
    qb.pos++
    return placeholder
}

func (qb *QueryBuilder) Where(condition string, args ...interface{}) {
    if qb.pos == 1 {
        qb.WriteString(" WHERE ")
    } else {
        qb.WriteString(" AND ")
    }
    
    for _, arg := range args {
        condition = strings.Replace(
            condition,
            "?",
            qb.AddArg(arg),
            1,
        )
    }
    
    qb.WriteString(condition)
}

func (qb *QueryBuilder) Build() (string, []interface{}) {
    return qb.sql.String(), qb.args
}

// Usage
func GetUsersByFilter(db *sql.DB, filter UserFilter) ([]User, error) {
    qb := NewQueryBuilder()
    qb.WriteString("SELECT id, name, email, created_at FROM users")
    
    if filter.Name != nil {
        qb.Where("name LIKE ?", "%"+*filter.Name+"%")
    }
    
    if filter.Email != nil {
        qb.Where("email = ?", *filter.Email)
    }
    
    query, args := qb.Build()
    rows, err := db.Query(query, args...)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    var users []User
    for rows.Next() {
        var user User
        if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt); err != nil {
            return nil, err
        }
        users = append(users, user)
    }
    
    return users, rows.Err()
}

2. SQL Builder

type SQLBuilder struct {
    table     string
    columns   []string
    where     []string
    whereArgs []interface{}
    orderBy   string
    limit     *int
    offset    *int
}

func NewSQLBuilder(table string) *SQLBuilder {
    return &SQLBuilder{table: table}
}

func (b *SQLBuilder) Select(columns ...string) *SQLBuilder {
    b.columns = columns
    return b
}

func (b *SQLBuilder) Where(condition string, args ...interface{}) *SQLBuilder {
    b.where = append(b.where, condition)
    b.whereArgs = append(b.whereArgs, args...)
    return b
}

func (b *SQLBuilder) OrderBy(orderBy string) *SQLBuilder {
    b.orderBy = orderBy
    return b
}

func (b *SQLBuilder) Limit(limit int) *SQLBuilder {
    b.limit = &limit
    return b
}

func (b *SQLBuilder) Offset(offset int) *SQLBuilder {
    b.offset = &offset
    return b
}

func (b *SQLBuilder) BuildSelect() (string, []interface{}) {
    var query strings.Builder
    
    query.WriteString("SELECT ")
    if len(b.columns) > 0 {
        query.WriteString(strings.Join(b.columns, ", "))
    } else {
        query.WriteString("*")
    }
    
    query.WriteString(" FROM ")
    query.WriteString(b.table)
    
    if len(b.where) > 0 {
        query.WriteString(" WHERE ")
        query.WriteString(strings.Join(b.where, " AND "))
    }
    
    if b.orderBy != "" {
        query.WriteString(" ORDER BY ")
        query.WriteString(b.orderBy)
    }
    
    if b.limit != nil {
        query.WriteString(fmt.Sprintf(" LIMIT %d", *b.limit))
    }
    
    if b.offset != nil {
        query.WriteString(fmt.Sprintf(" OFFSET %d", *b.offset))
    }
    
    return query.String(), b.whereArgs
}

Best Practices

1. Prepared Statements

type UserStore struct {
    db *sql.DB
    
    insertUser  *sql.Stmt
    selectUser  *sql.Stmt
    updateUser  *sql.Stmt
    deleteUser  *sql.Stmt
}

func NewUserStore(db *sql.DB) (*UserStore, error) {
    store := &UserStore{db: db}
    
    // Prepare statements
    var err error
    store.insertUser, err = db.Prepare(`
        INSERT INTO users (name, email, created_at)
        VALUES ($1, $2, $3)
        RETURNING id
    `)
    if err != nil {
        return nil, err
    }
    
    store.selectUser, err = db.Prepare(`
        SELECT id, name, email, created_at
        FROM users
        WHERE id = $1
    `)
    if err != nil {
        return nil, err
    }
    
    store.updateUser, err = db.Prepare(`
        UPDATE users
        SET name = $1, email = $2
        WHERE id = $3
    `)
    if err != nil {
        return nil, err
    }
    
    store.deleteUser, err = db.Prepare(`
        DELETE FROM users
        WHERE id = $1
    `)
    if err != nil {
        return nil, err
    }
    
    return store, nil
}

func (s *UserStore) Close() error {
    var errs []error
    
    if err := s.insertUser.Close(); err != nil {
        errs = append(errs, err)
    }
    if err := s.selectUser.Close(); err != nil {
        errs = append(errs, err)
    }
    if err := s.updateUser.Close(); err != nil {
        errs = append(errs, err)
    }
    if err := s.deleteUser.Close(); err != nil {
        errs = append(errs, err)
    }
    
    if len(errs) > 0 {
        return fmt.Errorf("errors closing statements: %v", errs)
    }
    return nil
}

2. Query Validation

type QueryValidator struct {
    allowedColumns map[string]bool
    allowedTables  map[string]bool
}

func NewQueryValidator() *QueryValidator {
    return &QueryValidator{
        allowedColumns: map[string]bool{
            "id":         true,
            "name":      true,
            "email":     true,
            "created_at": true,
        },
        allowedTables: map[string]bool{
            "users":    true,
            "profiles": true,
        },
    }
}

func (v *QueryValidator) ValidateColumn(column string) error {
    if !v.allowedColumns[column] {
        return fmt.Errorf("invalid column: %s", column)
    }
    return nil
}

func (v *QueryValidator) ValidateTable(table string) error {
    if !v.allowedTables[table] {
        return fmt.Errorf("invalid table: %s", table)
    }
    return nil
}

func (v *QueryValidator) ValidateOrderBy(orderBy string) error {
    parts := strings.Split(orderBy, " ")
    if len(parts) > 2 {
        return fmt.Errorf("invalid order by clause: %s", orderBy)
    }
    
    if err := v.ValidateColumn(parts[0]); err != nil {
        return err
    }
    
    if len(parts) == 2 {
        direction := strings.ToUpper(parts[1])
        if direction != "ASC" && direction != "DESC" {
            return fmt.Errorf("invalid sort direction: %s", direction)
        }
    }
    
    return nil
}

3. Query Logging

type QueryLogger struct {
    db     *sql.DB
    logger *log.Logger
}

func NewQueryLogger(db *sql.DB, logger *log.Logger) *QueryLogger {
    return &QueryLogger{
        db:     db,
        logger: logger,
    }
}

func (ql *QueryLogger) logQuery(query string, args []interface{}, duration time.Duration, err error) {
    ql.logger.Printf(
        "Query: %s\nArgs: %v\nDuration: %s\nError: %v\n",
        query,
        args,
        duration,
        err,
    )
}

func (ql *QueryLogger) Query(query string, args ...interface{}) (*sql.Rows, error) {
    start := time.Now()
    rows, err := ql.db.Query(query, args...)
    ql.logQuery(query, args, time.Since(start), err)
    return rows, err
}

func (ql *QueryLogger) QueryRow(query string, args ...interface{}) *sql.Row {
    start := time.Now()
    row := ql.db.QueryRow(query, args...)
    ql.logQuery(query, args, time.Since(start), nil)
    return row
}

Common Patterns

1. Bulk Operations

type BulkInserter struct {
    db        *sql.DB
    tableName string
    columns   []string
    batchSize int
    values    []interface{}
    valueLen  int
}

func NewBulkInserter(db *sql.DB, tableName string, columns []string, batchSize int) *BulkInserter {
    return &BulkInserter{
        db:        db,
        tableName: tableName,
        columns:   columns,
        batchSize: batchSize,
        valueLen:  len(columns),
    }
}

func (bi *BulkInserter) Add(values ...interface{}) error {
    if len(values) != bi.valueLen {
        return fmt.Errorf(
            "invalid number of values: got %d, want %d",
            len(values),
            bi.valueLen,
        )
    }
    
    bi.values = append(bi.values, values...)
    
    if len(bi.values)/bi.valueLen >= bi.batchSize {
        return bi.Flush()
    }
    
    return nil
}

func (bi *BulkInserter) Flush() error {
    if len(bi.values) == 0 {
        return nil
    }
    
    valueCount := len(bi.values) / bi.valueLen
    placeholders := make([]string, valueCount)
    
    for i := 0; i < valueCount; i++ {
        placeholder := make([]string, bi.valueLen)
        for j := 0; j < bi.valueLen; j++ {
            placeholder[j] = fmt.Sprintf("$%d", i*bi.valueLen+j+1)
        }
        placeholders[i] = "(" + strings.Join(placeholder, ", ") + ")"
    }
    
    query := fmt.Sprintf(
        "INSERT INTO %s (%s) VALUES %s",
        bi.tableName,
        strings.Join(bi.columns, ", "),
        strings.Join(placeholders, ", "),
    )
    
    _, err := bi.db.Exec(query, bi.values...)
    if err != nil {
        return err
    }
    
    bi.values = bi.values[:0]
    return nil
}

2. Query Caching

type QueryCache struct {
    cache  map[string]*sql.Stmt
    db     *sql.DB
    mu     sync.RWMutex
}

func NewQueryCache(db *sql.DB) *QueryCache {
    return &QueryCache{
        cache: make(map[string]*sql.Stmt),
        db:    db,
    }
}

func (qc *QueryCache) GetStmt(query string) (*sql.Stmt, error) {
    qc.mu.RLock()
    stmt, ok := qc.cache[query]
    qc.mu.RUnlock()
    
    if ok {
        return stmt, nil
    }
    
    qc.mu.Lock()
    defer qc.mu.Unlock()
    
    // Double-check after acquiring write lock
    if stmt, ok := qc.cache[query]; ok {
        return stmt, nil
    }
    
    stmt, err := qc.db.Prepare(query)
    if err != nil {
        return nil, err
    }
    
    qc.cache[query] = stmt
    return stmt, nil
}

func (qc *QueryCache) Close() error {
    qc.mu.Lock()
    defer qc.mu.Unlock()
    
    var errs []error
    for _, stmt := range qc.cache {
        if err := stmt.Close(); err != nil {
            errs = append(errs, err)
        }
    }
    
    if len(errs) > 0 {
        return fmt.Errorf("errors closing statements: %v", errs)
    }
    return nil
}

Next Steps