GORM vs sqlx vs database/sql

Which to Use in Go

Use database/sql for raw performance, sqlx for easy struct mapping, or GORM for a full-featured ORM in Go.

GORM vs sqlx vs database/sql: Which to Use in Go

You're building a service that needs to store user data. You've got a database running. Now you need to talk to it. You open your terminal and type go get. Three names pop up in your search results: database/sql, sqlx, and gorm. Each one promises to solve the same problem, but they solve it in wildly different ways. Picking the wrong tool doesn't just change your syntax; it changes how you think about your data, how you debug production issues, and how fast your code runs.

The choice dictates your future. Pick the tool that matches your team's SQL literacy, not the trendiest package.

The abstraction ladder

Think of database access like getting a meal. database/sql is cooking from scratch. You buy the raw ingredients, chop the vegetables, manage the heat, and plate the food. You control every calorie and every flavor, but you spend hours in the kitchen. sqlx is a premium meal kit. The ingredients are pre-portioned and labeled. You still cook and assemble, but the tedious prep work is gone. GORM is a full-service restaurant. You order "User Profile" and the kitchen handles everything: sourcing, cooking, plating, and even remodeling the kitchen if you change your mind. You get food fast, but you have no idea what's in the sauce, and you can't ask for less salt without rewriting the menu.

Abstraction buys speed. It costs control. Know the trade-off.

Minimal examples

Here's how each library looks for the same task: fetching a single user by ID.

// database/sql: Raw, high-performance, manual mapping.
// You write the SQL and map columns to variables yourself.
import (
	"database/sql"
	_ "github.com/lib/pq" // Driver registration
)

// User represents a row in the users table.
type User struct {
	ID   int
	Name string
}

// FetchUserSQL demonstrates raw database/sql usage.
// You write the query and tell Go exactly which column goes to which variable.
func FetchUserSQL(db *sql.DB, id int) (User, error) {
	var u User
	// QueryRow returns a single row. Scan maps columns to pointers.
	// The order of arguments to Scan must match the SELECT order.
	err := db.QueryRow("SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name)
	if err != nil {
		return User{}, err
	}
	return u, nil
}
// sqlx: Struct mapping with named parameters.
// sqlx wraps database/sql and adds reflection-based scanning.
import (
	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"
)

// UserSQLX uses db tags to map columns to struct fields.
type UserSQLX struct {
	ID   int    `db:"id"`
	Name string `db:"name"`
}

// FetchUserSQLX uses sqlx to scan directly into a struct.
// Named parameters make queries readable and reusable.
func FetchUserSQLX(db *sqlx.DB, id int) (UserSQLX, error) {
	var u UserSQLX
	// Get runs the query and scans the result into the struct.
	// sqlx matches column names to struct tags automatically.
	err := db.Get(&u, "SELECT * FROM users WHERE id = :id", sqlx.Named("id", id))
	if err != nil {
		return UserSQLX{}, err
	}
	return u, nil
}
// GORM: Full ORM with auto-migrations.
// GORM generates SQL based on your struct and method calls.
import (
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

// UserGORM embeds gorm.Model for standard fields.
type UserGORM struct {
	gorm.Model // Embeds ID, CreatedAt, UpdatedAt, DeletedAt
	Name       string
}

// FetchUserGORM uses GORM's query builder.
// You rarely write raw SQL strings.
func FetchUserGORM(db *gorm.DB, id uint) (UserGORM, error) {
	var u UserGORM
	// First finds the first record matching the condition.
	// GORM builds the SQL query automatically.
	result := db.First(&u, id)
	if result.Error != nil {
		return UserGORM{}, result.Error
	}
	return u, nil
}

Manual scanning is tedious. It's also the most reliable way to ensure your data lands where you expect.

What happens under the hood

All three libraries eventually call the same driver code to talk to the database. database/sql is the standard library. It manages connection pools and sends queries to the driver. It returns rows as generic data. You have to convert those rows into Go values yourself.

sqlx wraps database/sql. It intercepts the query, runs it, and then uses reflection to look at your struct tags. It matches column names to struct fields and populates the struct. This adds a small runtime cost for reflection, but it saves you from writing Scan(&u.ID, &u.Name) every time. sqlx also supports named parameters. You write :id in your query and pass a map or struct. sqlx replaces the placeholders with positional arguments before sending the query to the driver. This makes complex queries easier to read and maintain.

GORM wraps everything else. It maintains a schema registry. When you call db.First, GORM constructs a SQL string, executes it, maps the result, and runs any hooks you defined. It also tracks changes to structs so it can generate INSERT and UPDATE statements automatically. This convenience comes with significant overhead. GORM can generate dozens of queries for a single operation if you aren't careful, and the reflection cost is higher. GORM also introduces "magic". Hooks like BeforeCreate or AfterFind run automatically. If you don't know the hooks exist, debugging becomes a treasure hunt.

Reflection is fast enough for most apps. It's not fast enough for high-frequency trading. Profile before you panic.

Realistic scenario: Relationships

Real code involves relationships. Fetching a user and their posts highlights the differences.

// database/sql: You write the JOIN. You handle the grouping.
// You have full control over the query plan.
func GetUserWithPostsSQL(db *sql.DB, id int) (User, []Post, error) {
	// You must write the JOIN manually.
	// You must handle the case where multiple posts map to the same user.
	rows, err := db.Query(`
		SELECT u.id, u.name, p.id, p.title 
		FROM users u 
		JOIN posts p ON u.id = p.user_id 
		WHERE u.id = $1`, id)
	if err != nil {
		return User{}, nil, err
	}
	defer rows.Close()

	var user User
	var posts []Post
	for rows.Next() {
		var p Post
		// Scan repeats user data for every post row.
		// You must deduplicate the user manually.
		err := rows.Scan(&user.ID, &user.Name, &p.ID, &p.Title)
		if err != nil {
			return User{}, nil, err
		}
		posts = append(posts, p)
	}
	return user, posts, rows.Err()
}
// sqlx: Struct mapping helps, but you still write the JOIN.
// You can use Select to get a slice, but grouping related data requires care.
func GetUserWithPostsSQLX(db *sqlx.DB, id int) (UserSQLX, []PostSQLX, error) {
	var user UserSQLX
	var posts []PostSQLX
	// sqlx doesn't auto-group. You still need to write the query.
	// Using a subquery or JOIN with manual grouping is common.
	err := db.Get(&user, "SELECT * FROM users WHERE id = :id", sqlx.Named("id", id))
	if err != nil {
		return UserSQLX{}, nil, err
	}
	err = db.Select(&posts, "SELECT * FROM posts WHERE user_id = :id", sqlx.Named("id", id))
	if err != nil {
		return UserSQLX{}, nil, err
	}
	return user, posts, nil
}
// GORM: Preload handles the relationship automatically.
// GORM generates two queries or a JOIN and groups the results.
func GetUserWithPostsGORM(db *gorm.DB, id uint) (UserGORM, error) {
	var user UserGORM
	// Preload fetches related records.
	// GORM decides whether to use a JOIN or a separate query.
	err := db.Preload("Posts").First(&user, id).Error
	if err != nil {
		return UserGORM{}, err
	}
	return user, nil
}

GORM's Preload is convenient, but if you forget it and access user.Posts in a loop, GORM might trigger a query for every user. This is the N+1 problem. database/sql and sqlx don't have this risk because you write the query explicitly. You can't accidentally trigger a query you didn't write.

ORMs hide queries. Hidden queries become production fires. Always inspect the generated SQL during development.

Pitfalls and errors

If you use database/sql and forget to scan a column, the compiler won't catch it. You get a runtime panic or a silent mismatch. If you pass the wrong number of arguments to Scan, you might see sql: expected 2 destination arguments in Scan, not 1. With sqlx, if your struct tag doesn't match the column name, the field stays zero-valued. The compiler won't warn you. You'll debug why user.Name is empty for hours.

GORM has its own error types. If you query for a user that doesn't exist, db.First returns gorm.ErrRecordNotFound. You must check for this specific error if you need to distinguish "not found" from a database failure. GORM also warns about auto-migration risks. If you run AutoMigrate in production without reviewing the diff, you might drop a column or alter a type unexpectedly. Always review migration plans before running them.

Database calls block. If a request times out, you need to cancel the query. All three libraries support context, but you have to pass it. In database/sql, use db.QueryRowContext. In sqlx, use db.GetContext. In GORM, use db.WithContext(ctx). If you skip context, your goroutines can leak waiting for a query that the client has already abandoned.

Context is plumbing. Run it through every long-lived call site.

Convention asides

Go has strong conventions around database code. The receiver name is usually one or two letters matching the type: (u *User) Save(...), NOT (this *User). Public names start with a capital letter. Private start lowercase. No keywords like public or private.

Error handling is explicit. You see if err != nil everywhere. This is by design. The community accepts the boilerplate because it forces you to handle failures at the point they occur. Don't try to hide errors in helper functions unless you wrap them with context using fmt.Errorf("fetch user: %w", err).

gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save. Your database code should look like everyone else's database code.

Decision matrix

Use database/sql when you need maximum performance and zero dependencies. Use database/sql when your queries are complex and dynamic, making ORM abstractions more trouble than they're worth. Use database/sql when you want full control over connection pooling and transaction management without a middleman.

Use sqlx when you want to write SQL but hate manual scanning. Use sqlx when you need struct mapping and named parameters to keep queries readable. Use sqlx when you want the safety of explicit SQL with the convenience of type-safe results.

Use GORM when you are building a prototype or an internal tool where development speed matters more than runtime performance. Use GORM when your data model is simple and CRUD-heavy, and you want auto-migrations and relationship handling out of the box. Use GORM when your team is more comfortable with high-level abstractions than raw SQL.

SQL is a language. Don't hide it behind a library unless you understand what the library is generating.

Where to go next