The login form that handed over the keys
You build a login page. A user types ' OR '1'='1 into the password field. Your server responds with the admin account details. You just handed the keys to the kingdom to anyone who knows a SQL trick. This isn't a theoretical risk. It happens when you stitch user input directly into SQL strings. The database sees the quotes and logic you accidentally injected and executes them as commands.
The fix requires a shift in how you construct queries. You stop building SQL commands by concatenating strings. You start describing data requests using placeholders. The database driver handles the separation between code and data. This pattern is the standard defense against SQL injection in Go and every other modern language.
Placeholders vs concatenation
SQL injection exploits the fact that a database engine executes whatever text it receives. If you construct a query by concatenating strings, the user becomes a co-author of the SQL. They can close a string literal, add a comment, or append a new clause. The database has no way to distinguish your code from their input.
Parameterized queries break this link. You send the query structure and the data separately. The database parses the structure first. It locks in the grammar. Then it applies the data. The data can never change the grammar.
Think of a parameterized query like a fill-in-the-blank form at the DMV. The form has fixed structure: "Name: _____", "DOB: _____". You can write "John" in the name box, or you can write "I am the King of France". The clerk doesn't turn you into the king. The structure is fixed. The input is just data in a box. Parameterized queries force the database to treat input as data in a box, never as part of the structure.
Minimal example
Here's the safe pattern: use a placeholder in the query string and pass the value as a separate argument. The database driver takes care of the rest.
package main
import (
"database/sql"
"log"
)
func main() {
// Open a connection to SQLite for demonstration.
// In production, use your database driver.
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Create a table to query against.
_, err = db.Exec("CREATE TABLE users (id INTEGER, name TEXT)")
if err != nil {
log.Fatal(err)
}
// Insert a safe row.
_, err = db.Exec("INSERT INTO users VALUES (1, 'Alice')")
if err != nil {
log.Fatal(err)
}
// Safe query: use '?' as a placeholder.
// The driver replaces '?' with the value safely.
row := db.QueryRow("SELECT name FROM users WHERE id = ?", 1)
var name string
// Scan reads the result into the variable.
// Pass a pointer to the variable so Scan can modify it.
err = row.Scan(&name)
if err != nil {
log.Fatal(err)
}
log.Println("Found:", name)
}
The placeholder ? marks where the value goes. The exact syntax depends on the driver. SQLite and MySQL use ?. PostgreSQL uses $1, $2. The driver handles the translation. You pass the value 1 as an argument to QueryRow. The driver binds that value to the placeholder. The database executes the query with the value treated as data.
What happens under the hood
When you call db.QueryRow with a placeholder, Go doesn't send a single string to the database. The database/sql package sends the query template with placeholders. The driver prepares the statement. The database compiles the SQL plan. It sees a hole where the value goes. Then Go sends the value. The driver binds the value to the hole. The database executes.
This separation is enforced by the wire protocol. Even if the value contains quotes, they are escaped or handled as binary data. The query plan doesn't change. The database never re-parses the SQL with the value inserted. The value is just a parameter bound to a slot in the execution plan.
This approach also improves performance. The database can cache the execution plan. If you run the same query with different values, the database reuses the plan. You get the safety of parameterization and the speed of prepared statements.
Realistic example: context and structs
In real code, you'll use db.QueryRowContext. The context is the first argument. This follows the Go convention: context travels through the call stack to handle cancellation and timeouts. If the user closes the browser tab, the context cancels, and the database query stops waiting.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
)
// User represents a row in the users table.
// Public fields start with a capital letter.
type User struct {
ID int
Name string
}
// GetUser retrieves a user by ID from the database.
// It uses parameterized queries to prevent injection.
func GetUser(ctx context.Context, db *sql.DB, id int) (*User, error) {
// Use QueryRowContext to support cancellation.
// The '?' placeholder is replaced by the driver.
row := db.QueryRowContext(ctx,
"SELECT id, name FROM users WHERE id = ?", id)
user := &User{}
// Scan maps columns to struct fields.
// Pass pointers to the fields.
err := row.Scan(&user.ID, &user.Name)
if err != nil {
// Wrap the error to provide context.
// The caller can check for sql.ErrNoRows.
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return user, nil
}
func main() {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Create table and insert data.
db.Exec("CREATE TABLE users (id INTEGER, name TEXT)")
db.Exec("INSERT INTO users VALUES (1, 'Alice')")
// Create a context with a timeout.
// The query will be cancelled if it takes too long.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
user, err := GetUser(ctx, db, 1)
if err != nil {
log.Fatal(err)
}
log.Printf("User: %+v", user)
}
The function takes a context.Context as the first parameter. This is the standard convention in Go. Functions that perform long-running operations accept a context so callers can control cancellation. The receiver name convention doesn't apply here since this is a function, not a method. If this were a method on a Repository type, the receiver would be (r *Repository) GetUser(...). One or two letters matching the type is the norm.
Error handling uses if err != nil. The community accepts this boilerplate because it makes the unhappy path visible. You can't accidentally swallow an error. The error is wrapped with fmt.Errorf and %w. This preserves the original error chain. Callers can use errors.Is to check for specific errors like sql.ErrNoRows.
Pitfalls and edge cases
The IN clause trap
You cannot pass a slice directly to a placeholder. If you have a list of IDs, you can't write WHERE id IN (?) and pass []int{1, 2, 3}. The driver doesn't know how to expand the slice. You must generate the correct number of placeholders dynamically.
package main
import (
"database/sql"
"fmt"
"strings"
)
// QueryMultiple shows how to handle IN clauses safely.
// You cannot pass a slice directly to a placeholder.
// You must generate the correct number of placeholders.
func QueryMultiple(db *sql.DB, ids []int) ([]string, error) {
if len(ids) == 0 {
return nil, nil
}
// Build placeholders: ?, ?, ?
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
// Join placeholders with commas.
query := fmt.Sprintf("SELECT name FROM users WHERE id IN (%s)",
strings.Join(placeholders, ","))
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
// Check for errors that happened during iteration.
if err := rows.Err(); err != nil {
return nil, err
}
return names, nil
}
This code builds a slice of ? strings and joins them. It also builds a slice of arguments. The args... syntax expands the slice into individual arguments for db.Query. This keeps the query parameterized. Every value is still bound safely.
Scanning types and pointers
Scan requires pointers to variables. If you pass a value instead of a pointer, the compiler rejects the program with cannot use name (variable of type string) as *string value in argument to row.Scan. You must pass &name.
Type mismatches also cause errors. If the database returns an integer and you scan into a string, the driver might fail or truncate data. Use the correct Go types. int64 for integers, string for text, []byte for blobs. For nullable columns, use sql.NullString or *string. Don't pass a *string for non-nullable values. Strings are cheap to pass by value. Pointers are only needed for Scan or nullable fields.
Driver syntax differences
Placeholder syntax varies by driver. SQLite and MySQL use ?. PostgreSQL uses $1, $2. If you switch drivers, you might need to change placeholders. Some drivers support both. Check the documentation. The compiler won't catch a mismatched placeholder. The driver returns a runtime error like pq: bind message supplies 1 parameters, but the prepared statement requires 0.
Manual escaping is wrong
Never try to escape SQL manually with strings.Replace or regex. You will miss edge cases. The driver handles escaping correctly for the specific database dialect. Trust the driver. Your manual escaping is likely incomplete and introduces new vulnerabilities.
Decision matrix
Use parameterized queries with ? or $1 placeholders for all queries that accept user input. Use database/sql with QueryRowContext when you need standard library compatibility and explicit control over connections. Use a library like sqlx or ent when you want helper methods for scanning into structs or code generation, provided they still use parameterized queries under the hood. Use raw string concatenation only for static identifiers like table names or column names that are hardcoded in your source code and never derived from user input.
Placeholders are cheap. Concatenation is a debt you can't repay.