ORM or Raw SQL in Go and Which One Fits Your Own Team
ORM or raw SQL in Go, decided with evidence: the SQL GORM sends, a loop of 4 queries where Preload needed 2, zero values skipped in updates, and a verdict

The argument between object relational mappers and raw SQL has run for decades and produces heat because both sides are right about something. An ORM saves you from writing and maintaining a thousand lines of repetitive queries and scanning code. Raw SQL saves you from learning what a library will do on your behalf, and from the day it does something you did not want. In Go the question has an extra twist, since the language's culture favours explicitness and its libraries for SQL are good. This article does not pick a winner for you. It shows what an ORM, GORM, actually sends to the database on Go 1.25.6 and PostgreSQL 18.6, so you can judge the trade with evidence, and then gives a decision by team and project.
Tested with Go 1.25.6, GORM v1.31.2 with its PostgreSQL driver v1.6.3, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It belongs to the database cornerstone. It follows choosing a database and leads into the library comparison, where speed is measured, and the ORM ranking.
What an ORM sends
The only honest way to evaluate an ORM is to read its SQL. The lab installs a logger that records every statement GORM sends and runs seven typical operations against two tables, authors and books.
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Author struct {
ID uint
Name string
Books []Book
}
type Book struct {
ID uint
Title string
Pages int
Archived bool
AuthorID uint
Author Author
}
func (Author) TableName() string { return "authors649" }
func (Book) TableName() string { return "books649" }
// sqlLog records every statement GORM sends.
type sqlLog struct {
logger.Interface
stmts *[]string
}
func (l sqlLog) Trace(_ context.Context, begin time.Time, fc func() (string, int64), err error) {
sql, _ := fc()
if err != nil {
sql += " -- refused: " + err.Error()
}
*l.stmts = append(*l.stmts, sql)
}
func main() {
var stmts []string
db, err := gorm.Open(postgres.Open("postgres://postgres:[email protected]:55432/lab?sslmode=disable"), &gorm.Config{
Logger: sqlLog{logger.Discard, &stmts},
})
if err != nil {
log.Fatal(err)
}
for _, q := range []string{"drop table if exists books649", "drop table if exists authors649"} {
if err := db.Exec(q).Error; err != nil {
log.Fatal(err)
}
}
if err := db.AutoMigrate(&Author{}, &Book{}); err != nil {
log.Fatal(err)
}
show := func(label string) {
fmt.Printf("%s\n", label)
for _, s := range stmts {
fmt.Println(" " + strings.Join(strings.Fields(s), " "))
}
fmt.Printf(" (%d statements)\n", len(stmts))
stmts = stmts[:0]
}
stmts = stmts[:0]
ada := Author{Name: "Ada", Books: []Book{{Title: "Engines", Pages: 200}, {Title: "Notes", Pages: 90}}}
db.Create(&ada)
grace := Author{Name: "Grace", Books: []Book{{Title: "Compilers", Pages: 310}}}
db.Create(&grace)
stmts = stmts[:0]
// 1. A lookup by primary key.
var b Book
db.First(&b, 1)
show("1. db.First(&book, 1)")
// 2. The N plus one that lazy association loading invites, and the two ways out.
var books []Book
db.Find(&books)
for i := range books {
db.First(&books[i].Author, books[i].AuthorID)
}
show("2. a loop that loads each book's author")
db.Preload("Author").Find(&books)
show("2b. Preload(\"Author\")")
db.Joins("Author").Find(&books)
show("2c. Joins(\"Author\")")
// 3. A struct filter ignores zero values.
db.Where(&Book{Pages: 0, Archived: false, Title: "Notes"}).Find(&books)
show("3. Where(&Book{Pages: 0, Archived: false, Title: \"Notes\"}): the zero fields are dropped from the filter")
db.Where(map[string]any{"pages": 0, "title": "Notes"}).Find(&books)
show("3b. the same with a map keeps the zero")
// 4. Updates with a struct skip zero values; a map does not.
db.Model(&Book{}).Where("id = ?", 1).Updates(Book{Pages: 0, Archived: true})
show("4. Updates(Book{Pages: 0, Archived: true}): Pages is not set to 0")
db.Model(&Book{}).Where("id = ?", 1).Updates(map[string]any{"pages": 0, "archived": false})
show("4b. Updates(map{pages: 0, archived: false})")
// 5. An update with no condition.
err = db.Model(&Book{}).Update("pages", 1).Error
fmt.Println("5. Update with no where clause:", err)
show("5b. statements sent")
// 6. Selecting everything versus the columns needed.
var titles []string
db.Model(&Book{}).Pluck("title", &titles)
show("6. Pluck(\"title\")")
var count int64
db.Model(&Book{}).Where("pages > ?", 100).Count(&count)
show(fmt.Sprintf("6b. Count -> %d", count))
// 7. A transaction.
err = db.Transaction(func(tx *gorm.DB) error {
tx.Create(&Book{Title: "Rolled back", AuthorID: 1})
return fmt.Errorf("fail on purpose")
})
show("7. Transaction that returns an error: " + err.Error())
}1. db.First(&book, 1)
SELECT * FROM "books649" WHERE "books649"."id" = 1 ORDER BY "books649"."id" LIMIT 1
(1 statements)
2. a loop that loads each book's author
SELECT * FROM "books649"
SELECT * FROM "authors649" WHERE "authors649"."id" = 1 ORDER BY "authors649"."id" LIMIT 1
SELECT * FROM "authors649" WHERE "authors649"."id" = 1 ORDER BY "authors649"."id" LIMIT 1
SELECT * FROM "authors649" WHERE "authors649"."id" = 2 ORDER BY "authors649"."id" LIMIT 1
(4 statements)
2b. Preload("Author")
SELECT * FROM "authors649" WHERE "authors649"."id" IN (1,2)
SELECT * FROM "books649"
(2 statements)
2c. Joins("Author")
SELECT "books649"."id","books649"."title","books649"."pages","books649"."archived","books649"."author_id","Author"."id" AS "Author__id","Author"."name" AS "Author__name" FROM "books649" LEFT JOIN "authors649" "Author" ON "books649"."author_id" = "Author"."id"
(1 statements)
3. Where(&Book{Pages: 0, Archived: false, Title: "Notes"}): the zero fields are dropped from the filter
SELECT * FROM "books649" WHERE "books649"."title" = 'Notes'
(1 statements)
3b. the same with a map keeps the zero
SELECT * FROM "books649" WHERE "books649"."pages" = 0 AND "books649"."title" = 'Notes'
(1 statements)
4. Updates(Book{Pages: 0, Archived: true}): Pages is not set to 0
UPDATE "books649" SET "archived"=true WHERE id = 1
(1 statements)
4b. Updates(map{pages: 0, archived: false})
UPDATE "books649" SET "archived"=false,"pages"=0 WHERE id = 1
(1 statements)
5. Update with no where clause: WHERE conditions required
5b. statements sent
UPDATE "books649" SET "pages"=1 -- refused: WHERE conditions required
(1 statements)
6. Pluck("title")
SELECT "title" FROM "books649"
(1 statements)
6b. Count -> 1
SELECT count(*) FROM "books649" WHERE pages > 100
(1 statements)
7. Transaction that returns an error: fail on purpose
INSERT INTO "books649" ("title","pages","archived","author_id") VALUES ('Rolled back',0,false,1) RETURNING "id"
(1 statements)The easy case is easy
db.First(&book, 1) sent SELECT * FROM "books649" WHERE "books649"."id" = 1 ORDER BY "books649"."id" LIMIT 1. That is what you would write, plus ORDER BY for determinism and a limit. For lookups, inserts, simple updates and filters, an ORM produces the SQL you would have written, and saves the typing and the scanning code. This is the honest case for the tool, and it is most of the CRUD in most applications.
The loop
The second block is the classic trap. The program loads all books and then, in a loop, loads each book's author with db.First(&books[i].Author, ...). For two authors and three books it sent 4 statements: one for the books and one per book. At a hundred books it would be 101. The ORM did exactly what the code asked, and nothing in the code looks wrong. This is the N plus one from finding N plus one queries, and ORMs make it easy to write because association access looks like a field read.
The ORM also offers the cure. Preload("Author") sent 2 statements: the authors with an IN (1,2) list, then the books. Joins("Author") sent 1, a LEFT JOIN with the author columns aliased. Both are correct tools for different shapes: preload for one to many relations, to avoid a row explosion, and a join for one to one. The point is that you have to know to ask, and that the default path is the slow one.
Zero values
The third and fourth blocks are subtler, and they cause real bugs.
Where(&Book{Pages: 0, Archived: false, Title: "Notes"}) sent WHERE "books649"."title" = 'Notes'. The struct had three fields set to values, and two of them are Go zero values, so GORM treats them as "not set" and left them out of the filter. A developer who meant "books with zero pages and not archived and titled Notes" got a broader query. With a map, Where(map[string]any{"pages": 0, "title": "Notes"}), the SQL contains "pages" = 0, as asked.
The same rule applies to updates. Updates(Book{Pages: 0, Archived: true}) sent UPDATE "books649" SET "archived"=true WHERE id = 1: pages was not set to zero, silently. To write a zero you pass a map or use Select("pages") to name the column. The behaviour is documented, it is consistent, and it is also a standing hazard for anyone who treats a struct as a description of the row they want. sql.NullInt64 or pointer fields change what counts as unset, at the cost of clumsier models.
A guard rail
The fifth block shows a protection. db.Model(&Book{}).Update("pages", 1) with no condition returned WHERE conditions required and, as the recorded statement shows, was refused: GORM will not run a mass update or delete without a where, unless you opt in. It is a feature that raw SQL does not have, and a reason some teams prefer an ORM for code written by many hands.
Fewer columns, counts, transactions
Pluck("title") selected one column. Count produced SELECT count(*) ... WHERE pages > 100. The transaction block ran the insert inside db.Transaction and, because the function returned an error, rolled back: the statements the logger sees are the ones that reach the server, and the begin and rollback are protocol level calls. GORM also wraps every single write in a transaction by default, which costs a round trip; the benchmark measures it and shows how to turn it off.
What raw SQL costs
Raw SQL means writing the query and the code that scans rows into structs, for every query. That is boilerplate, and boilerplate is where typos hide: a column added to the query and forgotten in the scan, an argument in the wrong order, an ErrNoRows ignored. The Go ecosystem has two answers that keep SQL as the source of truth:
sqlxadds struct scanning (db.Get,db.Select) on top ofdatabase/sql. It removes the scanning code and keeps the queries as strings, checked at run time. See sqlx in Go.sqlcgenerates typed Go functions from SQL files at build time. The query is checked against your schema when you generate, and a mistake is a compile error. See sqlc from install to type safe queries.
With either, a complex query is written in SQL, where you can see and tune it, and a simple one costs a few lines. Dynamic queries, with optional filters, are the weak spot, and dynamic queries without string concatenation covers the safe ways.
The decision
| Your situation | Lean toward |
|---|---|
| The team reads SQL comfortably and queries matter (reports, joins, performance) | SQL with sqlc or sqlx |
| Many simple CRUD tables, a small team and a deadline | An ORM |
| A schema that changes weekly and models that follow it | An ORM with migrations, or sqlc with a migration tool |
| Performance sensitive paths and complex queries beside simple CRUD | Both: an ORM for CRUD, SQL for the hot paths |
| Several databases to support | An ORM, or database/sql with care |
| A team new to databases | Raw SQL first, so they learn what is sent, then decide |
Two points make the choice less binary than the argument suggests. You can mix: GORM and bun both let you run raw SQL and scan into structs, and nothing stops a service using an ORM for 80 percent of its code and sqlc for the rest. And the choice is about who is surprised. An ORM's surprises are semantic: zero values, lazy loading, default transactions, hooks that run on save. Raw SQL's surprises are mechanical: a typo, a missing column. The first kind is harder to see in code review.
If you choose an ORM
Reduce the surprises with habits:
- Log statements in development at a level that shows the SQL, as the lab does, and read them. Make that part of reviewing a feature.
- Count statements per request in tests, with a threshold, so an N plus one fails the build.
- Pass maps or
Selectfor updates that must write zero values, and wrap them in functions with names that say so. - Disable the default transaction on single statement writes if you measure it, and keep explicit transactions for multi step work.
- Keep models separate from API types, so a changed column does not change your JSON.
- Prefer migrations written as SQL to automatic schema syncing, which is for prototypes; see migrations with goose.
- Know how to drop to SQL for the queries that matter, and do it without guilt.
If you choose SQL
Pick a generator or a scanner rather than writing every scan by hand. Keep queries in .sql files so they can be linted and reviewed as SQL. Test every query against a real database, since SQL errors are runtime errors without a generator. And write the repository or store layer thin, as in keeping SQL out of handlers, so the rest of the code does not depend on how you query.
Neither choice is wrong, and both are bad when made out of habit. The reading that serves a team best is to run an ORM with its SQL visible, look at what it sends for your three most important queries, and decide whether you would have written them. If yes, the ORM is saving you time. If you keep fighting it, you have your answer. The behaviours above are documented in the GORM guide on queries and updates, which describe the zero value rules and the blocked global updates quoted here.


