Sameer Kumar
← Blog

GORM, Jet and sqlc — The Good, the Bad and the 500

9 min read

GORM, Jet and sqlc — The Good, the Bad and the 500

Confession time. I come from Rails, Node and Django land. Over there, nobody asks which database library you want. Rails hands you ActiveRecord, Django hands you its ORM, and you go back to arguing about tabs vs spaces.

Go doesn’t hand you anything. It gives you database/sql, a shrug, and a reading list.

Disclaimer: This is not a benchmark. Nobody was load-tested in the making of this article. It’s one developer, one table and a lot of opinions, so take it with a pinch of oregano. 🍕

So, while learning Go properly (the “I’ll stop copy-pasting from Stack Overflow” kind of properly), I built Yummy: a photo grid of food. Upload a photo, give it a name, a caption and a rating out of 5. Five endpoints, one table, an Angular frontend. Small enough to finish, real enough to hurt.

And then I did what every developer does with a perfectly working app. I broke it on purpose. Twice.

Same Gin handlers, same Postgres, same table. Only the data layer changes. The code is all on GitHub at sameer1612/yummy if you want to poke at it, or judge me. Both are welcome.

The table everyone fights over

Here’s the poor table that went through all of this:

create table food_items (
  id serial primary key,
  name text not null,
  caption text not null,
  rating float8 check (rating >= 0 and rating <= 5),
  photo_path text not null,
  created_at timestamp default current_timestamp,
  updated_at timestamp default current_timestamp
);

Look at rating. It’s the one nullable column that matters: a pizza nobody has rated yet. Sounds innocent, right? Every library here has a strong opinion about it. Keep an eye on this little guy.

Round 1: sqlc, SQL first

sqlc flips the whole ORM idea on its head. You write plain old SQL, sprinkle a magic comment on top, and it writes the Go for you. The robot does the boring part. As it should.

-- name: CreateFoodItem :one
INSERT INTO food_items (name, caption, rating, photo_path)
VALUES ($1, $2, $3, $4)
RETURNING *;

Run sqlc generate, and out pops a params struct and a method:

food, err := queries.CreateFoodItem(ctx, db.CreateFoodItemParams{
	Name:      "Pizza",
	Caption:   "Delicious cheese pizza",
	Rating:    sql.NullFloat64{Float64: 4.5, Valid: true},
	PhotoPath: "uploads/images/pizza.png",
})

The good stuff

The not-so-good stuff

Remember our little rating guy? sqlc turns him into sql.NullFloat64. JSON, on the other hand, wants a *float64. They don’t talk to each other. So I played marriage counsellor, wrote a tiny helper pair, and called it from literally every handler:

func ToFloat64(n sql.NullFloat64) *float64 {
	if n.Valid {
		return &n.Float64
	}
	return nil
}

Not hard. Just one more bag to carry around everywhere.

The other thing: sqlc needs to know your schema. I pointed it at my migration file, which I run by hand with psql like a caveman. It works. But now the schema lives in a file, and the actual database is free to quietly disagree with it. Who wins that argument? Nobody. You find out in production.

Round 2: go-jet, Go first

go-jet goes the other way round. It connects to your live database, looks around, and generates Go types for every table and column. Then you write SQL… in Go:

err := tbl.INSERT(tbl.Name, tbl.Caption, tbl.Rating, tbl.PhotoPath).
	MODEL(model.FoodItems{Name: name, Caption: caption, Rating: rating, PhotoPath: photoPath}).
	RETURNING(tbl.AllColumns).
	Query(r.db, &result)

Yes, those are capital letters. Yes, it looks like your code is yelling at you. No, it doesn’t stop. You get used to it, like the neighbour’s dog.

The good stuff

And then it bit me

My handlers checked for a missing row the way sqlc had taught me:

if err == sql.ErrNoRows {
	c.JSON(404, gin.H{"error": "not found"})
}

Jet doesn’t return sql.ErrNoRows. It returns its own qrm.ErrNoRows. So on the jet branch, asking for a pizza that doesn’t exist gets you a shiny 500, not a 404. Everything compiles. The happy path works. You only find out when someone asks for food ID 9999 and your server throws a tantrum.

Lesson learnt: when you swap a data layer, its errors are part of its API too. Grep for every sentinel error you compare against. And use errors.Is, not ==. Always. Tattoo it somewhere.

Round 3: GORM, the comfort food

GORM is the one that felt like home. Write a plain struct, and the library figures out the rest:

type FoodItem struct {
	ID        int32 `gorm:"primaryKey"`
	Name      string
	Caption   string
	Rating    *float64
	PhotoPath string
}

result := r.db.Order("created_at").Find(&items)

No codegen step. No SQL file. FoodItem magically becomes food_items, just like Rails pluralises your table names behind your back. My Rails heart did a little happy dance. 💃

The good stuff

The not-so-good stuff

Side by side

If you skipped straight here, I see you. Here’s the whole thing in one table:

sqlc go-jet GORM
You write SQL SQL-shaped Go Go structs and method calls
Schema comes from your migration files the live database your structs
Nullable rating sql.NullFloat64 *float64 *float64
Not found sql.ErrNoRows qrm.ErrNoRows gorm.ErrRecordNotFound
Update / delete one query (RETURNING) one query (RETURNING) read, then write
Mistakes caught at generate time compile time runtime
Dynamic queries awkward natural natural

The repository pattern snuck in

One more thing changed in both rewrites, and it had nothing to do with the libraries. With sqlc, my handlers called the generated *db.Queries directly. Lazy? Maybe. Fast? Definitely. For jet and GORM, I put a small interface in between:

type FoodRepository interface {
	List() ([]FoodItem, error)
	GetByID(id int32) (FoodItem, error)
	Create(name, caption, photoPath string, rating *float64) (FoodItem, error)
	Update(id int32, name, caption, photoPath string, rating *float64) (FoodItem, error)
	Delete(id int32) (FoodItem, error)
}

Swapping libraries a second time made the case for it all by itself. The handlers barely changed between jet and GORM, apart from that cursed not-found check. (In hindsight, those methods should also take a context.Context, like the sqlc ones did, so a cancelled request cancels its query too. Next time. Promise.)

Which one stayed?

main still runs on sqlc. The other two live on their branches, like ex-roommates who still get the occasional letter.

My honest take after writing the same five endpoints three times:

FAQs

  1. So, which one is the best?
    — The one your team will actually read. Next question.
  2. Is GORM slow?
    — For a pizza photo app? No. For a busy table doing read-then-write everywhere? Measure it before you blame it.
  3. Why not just use database/sql directly?
    — You can. Then you’ll slowly write your own tiny sqlc by hand, one Scan at a time.

So, what did I learn?

Want to see the 500 for yourself? Clone the repo, check out the jet branch, and ask for pizza number 9999. Then switch to gorm and watch it become a polite 404.

That’s all, folks. Now go build something small, and then break it. Twice. 😇