Skip to content
gohacks.dev
Databases

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

10 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

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.

main.goGo
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())
}
Text
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:

  • sqlx adds struct scanning (db.Get, db.Select) on top of database/sql. It removes the scanning code and keeps the queries as strings, checked at run time. See sqlx in Go.
  • sqlc generates 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 situationLean 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 deadlineAn ORM
A schema that changes weekly and models that follow itAn ORM with migrations, or sqlc with a migration tool
Performance sensitive paths and complex queries beside simple CRUDBoth: an ORM for CRUD, SQL for the hot paths
Several databases to supportAn ORM, or database/sql with care
A team new to databasesRaw 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:

  1. 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.
  2. Count statements per request in tests, with a threshold, so an N plus one fails the build.
  3. Pass maps or Select for updates that must write zero values, and wrap them in functions with names that say so.
  4. Disable the default transaction on single statement writes if you measure it, and keep explicit transactions for multi step work.
  5. Keep models separate from API types, so a changed column does not change your JSON.
  6. Prefer migrations written as SQL to automatic schema syncing, which is for prototypes; see migrations with goose.
  7. 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.