DEV Community

Cover image for Nil Pointer Panic at 3 AM: Choosing the Right Go Database Tool to Save Your Sleep
Md. Maruf Sarker
Md. Maruf Sarker

Posted on

Nil Pointer Panic at 3 AM: Choosing the Right Go Database Tool to Save Your Sleep

It’s a familiar horror story for backend developers: a frantic alert wakes you up, and you trace the production error back to a nil pointer dereference.

The culprit? A simple typo in a raw SQL query string that went unnoticed until it was too late.

This isn't just a bug; it's a symptom of the tools we use.

In Go, how we talk to our database can mean the difference between compile-time confidence and runtime nightmares.

So, how do we choose the right tool for the job?

Let's explore four popular approaches—database/sql, SQLX, GORM, and SQLC—by looking at what they are, why you'd use them, and how they handle a simple, real-world task: fetching a user account from a database.

The Foundation: database/sql

What is it?

This is Go's built-in, standard library for database access. It provides a lightweight, no-frills interface for executing raw SQL queries.

It's not a driver itself but defines the standard interface that drivers for databases like PostgreSQL or MySQL must implement.

Why use it?

You use database/sql when you need maximum performance and direct control.

There are no abstractions between you and your SQL, meaning no hidden overhead.

How does it work?

Let's fetch an account. Notice the manual, error-prone process of scanning each column into a struct field.

// Assume db is an initialized *sql.DB

type Account struct {
    ID        int64
    Owner     string
    Balance   int64
    Currency  string
    CreatedAt time.Time
}

func GetAccount(db *sql.DB, id int64) (Account, error) {
    var account Account
    row := db.QueryRow("SELECT id, owner, balance, currency, created_at FROM accounts WHERE id = $1", id)

    // Manually scan each column into the struct's fields.
    // A mismatch in order or a new column will break this at runtime.
    err := row.Scan(
        &account.ID,
        &account.Owner,
        &account.Balance,
        &account.Currency,
        &account.CreatedAt,
    )
    if err != nil {
        return Account{}, err
    }
    return account, nil
}
Enter fullscreen mode Exit fullscreen mode

The Verdict:
Powerful and fast, but verbose and risky for anything complex.
A typo in the SELECT statement or changing the column order will only be caught when row.Scan fails in production.

The Convenient Extension: SQLX

What is it?

sqlx is a popular library that builds directly on top of database/sql.
It doesn't change the fundamentals but adds a set of extensions to make common tasks dramatically easier, especially scanning query results into structs.

Why use it?

You want the performance and control of raw SQL but hate the boilerplate of manual scanning.
sqlx is the perfect middle ground.

How does it work?

sqlx uses struct tags and reflection to automatically map column names to struct fields. Look how much cleaner our function becomes.

// Assume db is an initialized *sqlx.DB

type Account struct {
    ID        int64     `db:"id"`
    Owner     string    `db:"owner"`
    Balance   int64     `db:"balance"`
    Currency  string    `db:"currency"`
    CreatedAt time.Time `db:"created_at"`
}

func GetAccount(db *sqlx.DB, id int64) (Account, error) {
    var account Account
    // No more manual scanning! sqlx handles it.
    err := db.Get(&account, "SELECT * FROM accounts WHERE id = $1", id)
    if err != nil {
        return Account{}, err
    }
    return account, nil
}
Enter fullscreen mode Exit fullscreen mode

The Verdict:
A fantastic quality-of-life improvement over database/sql.
It's still executing raw SQL, so query typos are a runtime problem, but the data mapping is much safer and less tedious.

The Full Abstraction: GORM

What is it?

GORM is a full-featured Object-Relational Mapper (ORM).
It abstracts away SQL entirely, allowing you to interact with your database using Go code and structs.

Why use it?

You want to develop quickly. GORM handles the SQL generation for common CRUD (Create, Read, Update, Delete) operations, saving you from writing repetitive queries.
It's great for rapid prototyping and standard application development.

How does it work?

With GORM, you call Go methods that the library translates into SQL. Our GetAccount function no longer contains any SQL.

// Assume db is an initialized *gorm.DB

// GORM uses struct tags for table and column mapping.
type Account struct {
    ID        int64 `gorm:"primaryKey"`
    Owner     string
    Balance   int64
    Currency  string
    CreatedAt time.Time
}

func GetAccount(db *gorm.DB, id int64) (Account, error) {
    var account Account
    // GORM generates the "SELECT * FROM accounts WHERE id = ?" query.
    result := db.First(&account, id)
    if result.Error != nil {
        return Account{}, result.Error
    }
    return account, nil
}
Enter fullscreen mode Exit fullscreen mode

The Verdict:
Incredibly fast for development, but it comes with trade-offs.
You're learning GORM's API instead of SQL, and the generated queries might not be as performant as hand-written ones.
It can feel like "magic," which can be difficult to debug on high-load systems.

The Compile-Time Guardian: SQLC

What is it?

SQLC is a modern marvel. It's not an ORM or a library; it's a code generator.
You write raw SQL queries in .sql files, and sqlc generates fully type-safe, idiomatic Go code that you can call in your application.

Why use it?

You want the performance of raw SQL and the safety of a compiler.
sqlc catches errors in your SQL before your application ever runs.

How does it work?

It's a two-step process. First, you write the SQL.

-- file: query.sql
-- name: GetAccount :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1;
Enter fullscreen mode Exit fullscreen mode

Then, you run the sqlc generate command. It creates a Go file with this function:

// file: query.sql.go (auto-generated by sqlc)

// ... (structs and db connection setup)

func (q *Queries) GetAccount(ctx context.Context, id int64) (Account, error) {
    row := q.db.QueryRowContext(ctx, getAccount, id)
    var i Account
    // The generated code is guaranteed to match your query.
    err := row.Scan(
        &i.ID,
        &i.Owner,
        &i.Balance,
        &i.Currency,
        &i.CreatedAt,
    )
    return i, err
}
Enter fullscreen mode Exit fullscreen mode

If you had a typo in query.sql (e.g., SELEC *), sqlc would fail to generate code, giving you an immediate error.

The Verdict:
The best of all worlds for many applications.
It gives you the full power of SQL, the performance of database/sql, and the safety of compile-time checks.
The initial setup is slightly more involved, but it pays for itself by preventing entire classes of runtime bugs.

Conclusion: Which Tool Should You Use?

  • For ultimate control and micro-optimizations: Stick with database/sql.
  • For a simple, ergonomic boost over the standard library: sqlx is your go-to.
  • For rapid development and CRUD-heavy apps: GORM will get you moving the fastest.
  • For building robust, performant, and type-safe applications: sqlc is the modern, superior choice that will save you from those 3 AM production alerts.

Choose wisely, and happy coding!

Top comments (0)