TL;DR
Three tools, three philosophies. sqlc turns hand-written SQL into type-safe Go and then disappears. Reach for it when you want control and predictable performance. GORM is the batteries-included ORM that ships features fast but leans on reflection and hides a few sharp edges. Ent models your database as a typed graph and generates an entire client from a Go schema, which pays off when your relationships get tangled. Starting a new Postgres service today, I default to sqlc on the query-heavy paths and only pull in a full ORM when the entity graph earns it.
| Fact | Value | Source | Verified |
|---|---|---|---|
| Fastest at 1–10 rows | GORM (reported) | JetBrains benchmark | Sep 14, 2026 |
| Fastest at 10k–15k rows | sqlc ≈ database/sql (reported) | JetBrains benchmark | Sep 14, 2026 |
| GORM at 15k rows | ~59.3M ns/op vs sqlc ~31.7M (reported, 2023) | JetBrains benchmark | Sep 14, 2026 |
| Type errors caught | sqlc & Ent: compile time · GORM: runtime | official docs | Sep 14, 2026 |
| Built-in migrations | Ent (Atlas) · GORM (AutoMigrate) · sqlc: none | official docs | Sep 14, 2026 |
Go never blessed a single ORM
Django ships one ORM. Rails ships Active Record. Go ships database/sql: a connection pool, prepared statements, and a whole lot of manual rows.Scan(&a, &b, &c). Everything past that is a decision you have to make yourself, and the three libraries most teams end up choosing between sit at very different points on the abstraction dial.
sqlc barely moves the dial: you keep writing SQL, it writes the boilerplate. GORM cranks it all the way over to “I never want to see a query again.” Ent lands somewhere stranger. It treats your schema as a graph of Go types and generates a fluent client around it. Same job, three worldviews, and the wrong pick shows up six months later as either a pile of hand-written Scan calls or a production incident you can’t easily trace back to a query.
I’ve shipped Go backends with all three. My first real service used GORM because it was the fast way to get CRUD working, and it was, right up until an Update silently skipped a field I’d set to false. That one bug, which I’ll get to below, is why I now read the generated SQL before I trust any abstraction. These days I mix approaches: sqlc for the endpoints that get hammered, and a heavier tool only where the relationship modeling genuinely helps. If you’ve read my take on Rust vs Go, you already know I care less about which tool is “modern” and more about which one I can debug at 2am.
Let me walk through each one with real code, then the numbers, then a straight recommendation.
sqlc: write SQL, get Go
sqlc behaves more like a compiler than an ORM. You hand it a schema and a set of queries, it reads them against a real parser for your database engine, and it emits type-safe Go functions. There’s no reflection and no query builder in the output: the generated code is roughly what you’d write by hand with database/sql, minus the tedium and the off-by-one Scan bugs.
The shape of it looks like this. You write a schema file and a query file:
-- schema.sql
CREATE TABLE authors (
id BIGSERIAL PRIMARY KEY,
name text NOT NULL,
bio text
);
-- query.sql
-- name: GetAuthor :one
SELECT * FROM authors WHERE id = $1 LIMIT 1;
-- name: ListAuthors :many
SELECT * FROM authors ORDER BY name;
-- name: CreateAuthor :one
INSERT INTO authors (name, bio) VALUES ($1, $2)
RETURNING *;
Point sqlc.yaml at those files, run sqlc generate, and you get a package with typed methods:
ctx := context.Background()
queries := db.New(conn)
author, err := queries.CreateAuthor(ctx, db.CreateAuthorParams{
Name: "Brian Kernighan",
Bio: pgtype.Text{String: "Co-author of The C Programming Language", Valid: true},
})
if err != nil {
return err
}
fmt.Println(author.ID, author.Name)
There isn’t much magic here, and what little there is happens at build time rather than run time. If you rename a column and forget to update a query, sqlc generate fails before your code ever compiles. That’s the same compile-time safety you get from Go’s type system generally, and it’s why sqlc pairs so well with the language. It supports PostgreSQL, MySQL, and SQLite, and it ships a vet command that runs lint rules against your queries.
The catch you only feel once you hit it: dynamic queries are painful. sqlc generates a static function per query, so a filter like “search by name, but only if the caller provided one” doesn’t map cleanly. You end up either writing several near-identical queries or reaching for sqlc.slice() and sqlc.narg() for the cases the engine supports. For a search endpoint with eight optional filters, sqlc is the wrong tool, and you’ll fall back to building SQL strings by hand. For everything else (the 90% of queries that are fixed shape), it’s the cleanest option Go offers.
The other thing sqlc deliberately does not do is migrations. It reads your schema; it doesn’t manage it. You bring your own migration tool (golang-migrate, Atlas, goose), which most teams already have. That’s a feature if you like decoupled tools and an annoyance if you wanted one thing to rule them all.
GORM: the ORM that ships
GORM is what most people picture when they hear “Go ORM.” You define structs, GORM maps them to tables, and you interact through a chainable API that never makes you type SQL. It handles associations, hooks, soft deletes, and it can auto-migrate your schema straight from struct definitions. For a prototype or an internal tool, you can go from empty repo to working CRUD in about fifteen minutes.
type Author struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"not null"`
Bio string
Books []Book // has-many association
}
db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
db.AutoMigrate(&Author{}, &Book{})
// Create
db.Create(&Author{Name: "Brian Kernighan"})
// Query with associations eager-loaded
var author Author
db.Preload("Books").First(&author, "name = ?", "Brian Kernighan")
That reads well, and for simple cases it works exactly like it looks. GORM’s reach is genuinely broad: it speaks PostgreSQL, MySQL, SQLite, and SQL Server, it has by far the largest community of the three, and if you Google a GORM problem someone has already solved it.
The sharp edge is what GORM does under the hood: it uses reflection to figure out what changed, and by default it ignores Go zero values on updates. Watch this:
// You want to set Bio back to empty and mark the author inactive.
db.Model(&author).Updates(Author{Bio: "", Active: false})
// GORM sees "" and false as zero values and SKIPS them.
// Nothing happens. No error. The row is unchanged.
The first time this bit me, I lost most of an afternoon convinced my transaction was rolling back. It wasn’t. GORM had decided that false meant “no value provided.” The fix is to pass a map[string]interface{} or use Select to name the columns explicitly, but you have to know that, and the API gives you no hint. That’s the tax you pay for the convenience: the abstraction is doing things you can’t see, and when it does the wrong thing, the SQL it generated is one layer away from you.
GORM also carries real runtime overhead. Every query goes through reflection and callback chains, which is invisible on a request that returns three rows and very visible on one that returns fifty thousand. I’ll put numbers on that in a moment. GORM is a convenience-first choice, and that’s fine as long as you pick it knowing you traded some control and some performance for speed of development.
Ent: your schema as a typed graph
Ent, which started at Facebook and is now maintained in the open by the team behind Atlas, takes the boldest position of the three. You describe your whole schema as Go code, including the edges (relationships) between entities, and Ent generates a fully typed client from it.
// ent/schema/author.go
func (Author) Fields() []ent.Field {
return []ent.Field{
field.String("name").NotEmpty(),
field.String("bio").Optional(),
}
}
func (Author) Edges() []ent.Edge {
return []ent.Edge{
edge.To("books", Book.Type),
}
}
Run go generate ./ent, and you get a client where every query, every field, and every traversal is statically typed:
author, err := client.Author.
Create().
SetName("Brian Kernighan").
Save(ctx)
// Graph traversal, fully type-checked
books, err := client.Author.
Query().
Where(author.NameEQ("Brian Kernighan")).
QueryBooks().
All(ctx)
Not one of those calls is a string, so a misspelled field or a query against an edge that doesn’t exist fails to compile, the same guarantee sqlc gives but extended across relationships and traversals. Ent’s real value shows up on a data model with many entities and many edges between them: it stays readable where raw SQL turns into a wall of JOINs. It also has first-class GraphQL generation, hooks, interceptors, and both automatic and versioned migrations through Atlas. If you’re building the kind of service where the shape of the data is the hard part, Ent is built for exactly that. The type-safety story here rhymes with what I liked about Go’s generic methods — push errors to compile time and let the tooling carry the weight.
The cost is upfront weight. Ent generates a large ent/ directory, often thousands of lines, and your build gets slower as the schema grows. The learning curve is steeper than GORM’s, and the “everything is generated” model means you’re commiting to Ent’s way of doing things fairly deeply. It’s the heaviest of the three to adopt and the hardest to back out of.
Feature comparison at a glance
| Feature | sqlc | GORM | Ent |
|---|---|---|---|
| Approach | SQL → generated Go | Struct-based ORM | Schema-as-Go graph |
| Type safety | Compile time | Runtime | Compile time |
| Uses reflection | No | Yes | No (generated) |
| Dynamic queries | Awkward | Easy | Easy |
| Built-in migrations | No | AutoMigrate | Yes (Atlas) |
| Relationships / edges | Manual JOINs | Associations | First-class edges |
| GraphQL generation | No | No | Yes |
| Learning curve | Low (if you know SQL) | Low | Steep |
| Generated code volume | Small | None | Large |
| Databases | PG, MySQL, SQLite | PG, MySQL, SQLite, SQL Server | PG, MySQL, SQLite, MariaDB |
A quick read of that table tells you most of what you need. sqlc and Ent share the compile-time-safety column; GORM trades it for convenience. GORM and Ent handle dynamic queries and relationships out of the box; sqlc makes you work for them. And only Ent tries to own your whole schema lifecycle.
Performance: what the numbers actually say
The abstraction tax stops being theoretical once you look at throughput. JetBrains ran a benchmark fetching from 1 to 15,000 rows out of a 15,000-record table, scanning each result into structs. The numbers below are theirs, reported from that 2023 run. I haven’t re-run them on 2026 hardware, but the shape matches what I’ve seen in production, so I trust the trend more than the absolute figures.
| Rows fetched | database/sql | sqlc | GORM |
|---|---|---|---|
| 1 | 124,134 ns | 147,056 ns | 89,251 ns |
| 10 | 157,780 ns | 256,384 ns | 136,556 ns |
| 100 | 427,603 ns | 456,938 ns | 563,539 ns |
| 1,000 | 2,201,303 ns | 2,313,674 ns | 4,186,201 ns |
| 10,000 | 21,690,323 ns | 21,558,300 ns | 40,463,924 ns |
| 15,000 | 32,048,808 ns | 31,680,017 ns | 59,348,697 ns |
The pattern is easy to read off. GORM wins the tiny queries: for one or ten rows, its overhead is actually lower, likely because of how it batches the round-trip. But the curve flips hard: by 15,000 rows GORM is nearly twice as slow as sqlc, and the gap only widens with volume. sqlc, meanwhile, tracks database/sql almost exactly, which makes sense, because the generated code basically is database/sql with the boilerplate filled in.
The practical takeaway: if an endpoint returns a handful of rows, the performance difference is noise, and you should optimize for developer speed instead. If an endpoint scans thousands of rows (reports, exports, analytics), the ORM overhead is a real line item, and that’s exactly where I move the query to sqlc. This is the same instinct that makes me reach for a columnar engine on heavy analytics, which I got into when comparing DuckDB vs Polars.
Migrations and type safety are where the real divergence lives
Performance grabs attention, but migrations and error-timing are what you’ll actually feel every day.
Migrations. GORM’s AutoMigrate reads your structs and alters the database to match. It’s frictionless for the first few months and genuinely risky later: it won’t drop columns, it can’t express complex data migrations, and “the schema is whatever the structs currently say” is a scary way to run production. Ent goes the opposite direction with versioned migrations through Atlas, giving you reviewable, diff-based schema changes. sqlc simply opts out and expects you to bring a real migration tool, which, honestly, you should be using anyway.
Type safety. This is the cleanest split of the three. With sqlc and Ent, a wrong column name is a compile error, so the failure happens on your machine, in CI, before anything ships. With GORM, that same mistake is a runtime error that surfaces when the query runs, which might be in a code path your tests didn’t cover. For a language whose entire selling point is catching mistakes at compile time, handing that guarantee back to get a nicer API is a trade I’ve grown more reluctant to make. If you want the JSON-handling side of that story too, Go’s revamped encoding/json v2 leans the same way: more of your marshaling errors show up earlier.
Which one should you use?
The right pick depends on what your service actually does. Here’s how I route the decision:
- Pick sqlc if you know SQL, care about performance, and your queries are mostly fixed in shape. It’s the best fit for high-throughput services, microservices with focused data access, and anyone who wants to see exactly what hits the database. This is my default in 2026.
- Pick GORM if you’re prototyping, building an internal tool, or your team wants to ship CRUD without thinking about SQL. Accept the runtime overhead and learn the zero-value gotcha on day one, not day ninety. It’s the right call when developer velocity beats raw speed.
- Pick Ent if your data model is genuinely complex — many entities, many relationships — or you need GraphQL. The upfront weight buys you a typed graph that stays readable where raw JOINs wouldn’t. It’s overkill for a three-table service and a lifesaver for a thirty-table one.
There’s also the honest hybrid answer, which is what most mature Go codebases I’ve seen actually do: use one tool for the boring 80% and drop to sqlc or raw SQL for the hot paths. Nothing stops you from running GORM for admin CRUD and sqlc for the analytics endpoint in the same binary. The libraries share the same database/sql connection pool underneath.
If you’re setting up the surrounding project scaffolding at the same time, my Go CLI frameworks comparison covers the other big “which library” decision you’ll hit early.
How I’d choose in 2026
For a fresh Postgres service I’m building today, I start with sqlc and a plain migration tool, and I run the examples against a managed database. A hosted Postgres instance on Supabase works fine for local development and staging without babysitting a container. I only reach for GORM or Ent when the modeling problem is real: if I catch myself writing the same six-way JOIN for the fourth time, that’s Ent telling me my data is a graph and I should stop pretending it’s a set of flat tables.
The mistake I see most often is picking the heaviest tool first “to be safe,” then fighting its abstractions for the rest of the project. Start light. You can always add an ORM later; ripping one out after it’s woven through every handler is the migration nobody volunteers for.
FAQ
Is sqlc faster than GORM?
For large result sets, yes, by a wide margin. In the reported JetBrains benchmark, sqlc tracks database/sql and stays roughly twice as fast as GORM at 15,000 rows. For very small queries (1–10 rows) GORM was actually faster in that run. The difference only shows up at volume; for a handful of rows it’s noise.
Should I use GORM or sqlc for a new project?
If you know SQL and want performance and predictability, start with sqlc. If you want to ship CRUD fast and don’t mind a full ORM’s overhead and quirks, GORM gets you moving quicker. Many teams use both in the same codebase: sqlc for hot paths, GORM for routine CRUD.
What is the difference between sqlc and Ent?
sqlc generates Go from SQL you write by hand and does nothing else: no migrations, no schema management. Ent generates an entire typed client from a Go-defined schema, including relationships, migrations via Atlas, and optional GraphQL. sqlc is lighter and SQL-first; Ent is heavier and schema-first.
Does sqlc support migrations?
No. sqlc reads your schema to generate code but does not manage it. Pair it with a dedicated migration tool such as golang-migrate, goose, or Atlas, which is standard practice regardless of which query library you use.
Is Ent worth the learning curve?
Only if your data model justifies it. For a service with many entities and complex relationships, Ent’s typed graph and generated client pay for themselves. For a small schema, the generated-code volume and slower builds are pure overhead, and sqlc or GORM will serve you better.
Sources
- sqlc documentation — official docs: how sqlc generates type-safe Go from SQL, supported databases, and the
vetcommand - sqlc GitHub repository — source, releases, and query-engine support
- GORM documentation — official ORM guide, including the update-with-zero-value behavior
- Ent documentation — schema-as-Go, edges, and Atlas-backed migrations
- Comparing database/sql, GORM, sqlx, and sqlc — JetBrains Go blog — the benchmark numbers cited above
- Comparing the best Go ORMs (2026) — Encore — independent comparison with additional context
Bottom line
There’s no universal answer, but there is a good default. Start with sqlc, keep a real migration tool beside it, and only add GORM or Ent when the problem you have is a modeling problem rather than a boilerplate problem. sqlc gives you Go’s compile-time safety with almost none of the abstraction cost, GORM buys you speed of development at a runtime and control price you should agree to on purpose, and Ent earns its weight the moment your schema turns into a graph. Pick the lightest tool that solves the problem in front of you. The heavy one will still be there when you actually need it.