SQL Injection in Go (database/sql, pgx, GORM)
fmt.Sprintf or + inside db.Query() lets a request parameter rewrite your SQL, turning a single-row lookup into a full table dump (CWE-89). The safe call for database/sql, pgx and GORM, the placeholder each driver expects, and how to handle ORDER BY columns.[1], [2], [3], [4], [6], [7]
Imagine sending a train manifest as two separate items: a fixed route sheet and sealed cargo tags for the passenger’s values. Go db.QueryRowContext(ctx, sql, args...) keeps the SQL and args separate; the driver fills the placeholder, such as $1 for PostgreSQL or ? for MySQL and SQLite. fmt.Sprintf writes the passenger’s words into the route sheet before the driver sees it, so they become SQL instructions. pgx and GORM Where can bind values too, but GORM Order takes raw SQL; map the sort key to an allowlisted clause.OrderByColumn instead.[2], [1], [3], [4], [12]
Core Concepts & Subsystem Terms
db.QueryRowContext(ctx, sql, args...)- database/sql sends everything after the SQL string as separate arguments. It is safe only when the values are in
args; a query built withfmt.Sprintfor+has already mixed data into the SQL before the driver sees it.[2], [11] Placeholders: $1 vs ?- database/sql does not rewrite placeholders; the driver does. pgx and lib/pq expect
$1,$2; go-sql-driver/mysql and the SQLite drivers expect?. A wrong style fails loudly, which is far better than falling back to Sprintf.[2], [12], [13] pgx and pgx.NamedArgs- pgx sends the values as typed parameters of a prepared statement, so PostgreSQL parses the SQL before any value arrives.
pgx.NamedArgsadds@nameplaceholders for queries with many values.[3]
Framework mappings
- CWE reference for the weakness described in this guide: CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
Source Code: Flaw vs. Secure Implementation
// store.go: each query below formats request data into the SQL text
package store
import (
"context"
"database/sql"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"gorm.io/gorm"
)
type User struct {
ID int64
Email string
}
// database/sql: Sprintf merges the value into the SQL before the driver sees it
func GetEmail(ctx context.Context, db *sql.DB, userID string) (string, error) {
var email string
q := fmt.Sprintf("SELECT email FROM users WHERE id = %s", userID)
err := db.QueryRowContext(ctx, q).Scan(&email)
return email, err
}
// pgx: string concatenation, the same mistake with a different operator
func CountOrders(ctx context.Context, pool *pgxpool.Pool, status string) (int64, error) {
var n int64
err := pool.QueryRow(ctx, "SELECT count(*) FROM orders WHERE status = '"+status+"'").Scan(&n)
return n, err
}
// GORM: a formatted Where condition is inserted as raw SQL
func FindByEmail(db *gorm.DB, email string) (User, error) {
var u User
err := db.Where(fmt.Sprintf("email = '%s'", email)).First(&u).Error
return u, err
}
// Order() takes raw SQL, here straight from the query string
func ListUsers(db *gorm.DB, sortColumn string) ([]User, error) {
var users []User
err := db.Order(sortColumn).Find(&users).Error
return users, err
}
// store.go: values are bound as arguments, columns come from an allowlist
package store
import (
"context"
"database/sql"
"github.com/jackc/pgx/v5/pgxpool"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type User struct {
ID int64
Email string
}
var sortableColumns = map[string]string{"id": "id", "email": "email", "created": "created_at"}
// database/sql: $1 placeholder, value passed as a separate argument
func GetEmail(ctx context.Context, db *sql.DB, userID int64) (string, error) {
var email string
err := db.QueryRowContext(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&email)
return email, err
}
// pgx: the value travels as a typed parameter of a prepared statement
func CountOrders(ctx context.Context, pool *pgxpool.Pool, status string) (int64, error) {
var n int64
err := pool.QueryRow(ctx, "SELECT count(*) FROM orders WHERE status = $1", status).Scan(&n)
return n, err
}
// GORM: ? placeholder, value bound by the driver
func FindByEmail(db *gorm.DB, email string) (User, error) {
var u User
err := db.Where("email = ?", email).First(&u).Error
return u, err
}
// allowlist lookup, then GORM quotes the column as an identifier
func ListUsers(db *gorm.DB, sortKey string) ([]User, error) {
column, ok := sortableColumns[sortKey]
if !ok {
column = "id"
}
var users []User
err := db.Order(clause.OrderByColumn{Column: clause.Column{Name: column}}).Find(&users).Error
return users, err
}
Engineering & System Hardening Checklist
- ✓Enable gosec in golangci-lint and fail the build on G201 (SQL built with fmt.Sprintf) and G202 (SQL built by string concatenation).[5], [1], [6]
- ✓Pass every value through the variadic args of
Query,QueryRoworExec, or through GORM's?placeholders — never through Sprintf,+orstrings.Builder.[2], [11], [3], [4] - ✓Parse IDs with
strconv.ParseIntin the handler and pass typed values down, so a malformed ID is rejected before any SQL is built.[10], [8], [9] - ✓Take ORDER BY columns from a map allowlist and pass them as
clause.OrderByColumn, or choose between constant strings. Never pass request data toOrder().[4], [14] - ✓Connect with a role limited to the tables the service needs and no DDL rights, so a missed injection cannot drop tables or read beyond them.[6]
Sources
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- Go database/sql — Avoiding SQL injection risk
- pgx — PostgreSQL driver and toolkit for Go
- GORM — Security (SQL injection)
- gosec — G201/G202 SQL query construction rules
- OWASP SQL Injection Prevention Cheat Sheet
- GORM Raw SQL: Prevent SQL Injection in Go
- Go net/http — Request
- Go net/url — URL.Query
- Go strconv — ParseInt
- Go database/sql — DB.QueryRowContext
- go-sql-driver/mysql — Parameter placeholders
- lib/pq — PostgreSQL driver
- GORM source — OrderByColumn quoting