Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc
A practical, code-heavy look on ORMs in GO
Most prominent ORMs for GO are GORM, Ent, Bun and sqlc. Here is a little comparison of them with examples of CRUD operations in pure GO.

TL;DR
- GORM: feature-packed and convenient; easiest to “just ship”, but has more runtime overhead.
- Ent: schema-as-code with generated, type-safe APIs; excellent for large codebases and refactors.
- Bun: lightweight, SQL-first query builder/ORM; fast with great Postgres features, explicit by design.
- sqlc (not an ORM per se but still): write SQL, get type-safe Go; best raw performance and control, no runtime magic.
Selection Criteria and Quick Comparison
My criteria is:
- Performance: latency/throughput, avoidable overhead, batch ops.
- DX: learning curve, type safety, debuggability, codegen friction.
- Ecosystem: docs, examples, activity, integrations (migrations, tracing).
- Feature set: relations, eager loading, migrations, hooks, raw SQL escape hatches.
| Tool | Paradigm | Type Safety | Relations | Migrations | Raw SQL Ergonomics | Typical Use Case |
|---|---|---|---|---|---|---|
| GORM | Active Record–style ORM | Medium (runtime) | Yes (tags, Preload/Joins) | Auto-migrate (opt-in) | db.Raw(...) |
Fast delivery, rich features, conventional CRUD apps |
| Ent | Schema → codegen → fluent API | High (compile-time) | First-class (edges) | Generated SQL (separate step) | entsql, custom SQL |
Large codebases, refactor-heavy teams, strict typing |
| Bun | SQL-first query builder/ORM | Medium–High | Explicit (Relation) |
Separate migrate package | Natural (builder + raw) | Performance-conscious services, Postgres features |
| sqlc | SQL → codegen functions (not an ORM) | High (compile-time) | Via SQL joins | External tool (e.g., golang-migrate) | It is SQL | Max control & speed; DBA-friendly teams |
CRUD by Example
Setting Up (PostgreSQL)
Use pgx or the tool’s native PG driver. Example DSN:
export DATABASE_URL='postgres://user:pass@localhost:5432/app?sslmode=disable'
Imports (common for all ORMs)
In the beginning of each file with go code example add:
import (
"context"
"os"
)
We’ll model a simple users table:
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
GORM
Init
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type User struct {
ID int64 `gorm:"primaryKey"`
Name string
Email string `gorm:"uniqueIndex"`
}
func newGorm() (*gorm.DB, error) {
dsn := os.Getenv("DATABASE_URL")
return gorm.Open(postgres.Open(dsn), &gorm.Config{})
}
// Auto-migrate (optional; be careful in prod)
func migrate(db *gorm.DB) error { return db.AutoMigrate(&User{}) }
CRUD
func gormCRUD(ctx context.Context, db *gorm.DB) error {
// Create
u := User{Name: "Alice", Email: "alice@example.com"}
if err := db.WithContext(ctx).Create(&u).Error; err != nil { return err }
// Read
var got User
if err := db.WithContext(ctx).First(&got, u.ID).Error; err != nil { return err }
// Update
if err := db.WithContext(ctx).Model(&got).
Update("email", "alice+1@example.com").Error; err != nil { return err }
// Delete
if err := db.WithContext(ctx).Delete(&User{}, got.ID).Error; err != nil { return err }
return nil
}
Notes
- Relations via struct tags +
Preload/Joins. - Transaction helper:
db.Transaction(func(tx *gorm.DB) error { ... }).
Ent
Schema definition (in ent/schema/user.go):
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
type User struct {
ent.Schema
}
func (User) Fields() []ent.Field {
return []ent.Field{
field.Int64("id").Unique().Immutable(),
field.String("name"),
field.String("email").Unique(),
}
}
Generate code
go run entgo.io/ent/cmd/ent generate ./ent/schema
Init
import (
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql"
_ "github.com/jackc/pgx/v5/stdlib"
"your/module/ent"
)
func newEnt() (*ent.Client, error) {
dsn := os.Getenv("DATABASE_URL")
drv, err := sql.Open(dialect.Postgres, dsn)
if err != nil { return nil, err }
return ent.NewClient(ent.Driver(drv)), nil
}
CRUD
func entCRUD(ctx context.Context, client *ent.Client) error {
// Create
u, err := client.User.Create().
SetName("Alice").
SetEmail("alice@example.com").
Save(ctx)
if err != nil { return err }
// Read
got, err := client.User.Get(ctx, u.ID)
if err != nil { return err }
// Update
if _, err := client.User.UpdateOneID(got.ID).
SetEmail("alice+1@example.com").
Save(ctx); err != nil { return err }
// Delete
if err := client.User.DeleteOneID(got.ID).Exec(ctx); err != nil { return err }
return nil
}
Notes
- Strong typing end-to-end; edges for relations.
- Generated migrations or use your migration tool of choice.
Bun
Init
import (
"database/sql"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
_ "github.com/jackc/pgx/v5/stdlib"
)
type User struct {
bun.BaseModel `bun:"table:users"`
ID int64 `bun:",pk,autoincrement"`
Name string `bun:",notnull"`
Email string `bun:",unique,notnull"`
}
func newBun() (*bun.DB, error) {
dsn := os.Getenv("DATABASE_URL")
sqldb, err := sql.Open("pgx", dsn)
if err != nil { return nil, err }
return bun.NewDB(sqldb, pgdialect.New()), nil
}
CRUD
func bunCRUD(ctx context.Context, db *bun.DB) error {
// Create
u := &User{Name: "Alice", Email: "alice@example.com"}
if _, err := db.NewInsert().Model(u).Exec(ctx); err != nil { return err }
// Read
var got User
if err := db.NewSelect().Model(&got).
Where("id = ?", u.ID).
Scan(ctx); err != nil { return err }
// Update
if _, err := db.NewUpdate().Model(&got).
Set("email = ?", "alice+1@example.com").
WherePK().
Exec(ctx); err != nil { return err }
// Delete
if _, err := db.NewDelete().Model(&got).WherePK().Exec(ctx); err != nil { return err }
return nil
}
Notes
- Explicit joins/eager loading with
.Relation("..."). - Separate
bun/migratepackage for migrations.
sqlc
sqlc technically is not an ORM. You write SQL; it generates type-safe Go methods.
sqlc.yaml
version: "2"
sql:
- engine: postgresql
queries: db/queries
schema: db/migrations
gen:
go:
package: db
out: internal/db
sql_package: "database/sql" # or "github.com/jackc/pgx/v5"
Queries (db/queries/users.sql)
-- name: CreateUser :one
INSERT INTO users (name, email)
VALUES ($1, $2)
RETURNING id, name, email;
-- name: GetUser :one
SELECT id, name, email FROM users WHERE id = $1;
-- name: UpdateUserEmail :one
UPDATE users SET email = $2 WHERE id = $1
RETURNING id, name, email;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
Generate
sqlc generate
Usage
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib"
"your/module/internal/db"
)
func sqlcCRUD(ctx context.Context) error {
dsn := os.Getenv("DATABASE_URL")
sqldb, err := sql.Open("pgx", dsn)
if err != nil { return err }
q := db.New(sqldb)
// Create
u, err := q.CreateUser(ctx, db.CreateUserParams{
Name: "Alice", Email: "alice@example.com",
})
if err != nil { return err }
// Read
got, err := q.GetUser(ctx, u.ID)
if err != nil { return err }
// Update
up, err := q.UpdateUserEmail(ctx, db.UpdateUserEmailParams{
ID: got.ID, Email: "alice+1@example.com",
})
if err != nil { return err }
// Delete
if err := q.DeleteUser(ctx, up.ID); err != nil { return err }
return nil
}
Notes
- Bring your own migrations (e.g.,
golang-migrate). - For dynamic queries: write multiple SQL variants or combine with a small builder.
Performance Notes
- GORM: convenient but adds reflection/abstraction overhead. Fine for typical CRUD; watch out for N+1 queries (prefer
Joinsor selectivePreload). - Ent: generated code avoids reflection; good for complex schemas. Often faster than heavy, runtime-magic ORMs.
- Bun: thin over
database/sql; fast, explicit, great for batch ops and large result sets. - sqlc: essentially raw SQL performance with compile-time safety.
General tips
- Use pgx for the driver (v5) and context everywhere.
- Prefer batching (
COPY, multi-rowINSERT) for high throughput. - Profile SQL:
EXPLAIN ANALYZE, indexes, covering indexes, avoid unnecessary roundtrips. - Reuse connections; tune pool size based on workload.
Developer experience and Ecosystem
- GORM: biggest community, lots of examples/plugins; steeper learning curve for advanced patterns.
- Ent: great docs; codegen step is the main mental model shift; super refactor-friendly.
- Bun: readable, predictable queries; smaller but active community; excellent Postgres niceties.
- sqlc: minimal runtime deps; integrates nicely with migration tools and CI; superb for teams comfortable with SQL.
Feature Highlights
- Relations & eager loading: all handle relations; GORM (tags +
Preload/Joins), Ent (edges +.With...()), Bun (Relation(...)), sqlc (you write the joins). - Migrations: GORM (auto-migrate; careful in prod), Ent (generated/diff SQL), Bun (
bun/migrate), sqlc (external tools). - Hooks/Extensibility: GORM (callbacks/plugins), Ent (hooks/middleware + template/codegen), Bun (middleware-like query hooks, easy raw SQL), sqlc (compose in your app layer).
- JSON/Arrays (Postgres): Bun and GORM have nice helpers; Ent/sqlc handle via custom types or SQL.
When to Choose What
- Pick GORM if you want maximum convenience, rich features, and fast prototyping for conventional CRUD services.
- Pick Ent if you value compile-time safety, explicit schemas, and long-term maintainability in larger teams.
- Pick Bun if you want performance and explicit SQL-shaped queries with ORM comforts where it helps.
- Pick sqlc if you (and your team) prefer pure SQL with type-safe Go bindings and zero runtime overhead. sqlc is also a natural fit for the read model side of a CQRS architecture in Go, where queries are shaped for callers rather than domain entities and explicit SQL gives you full control over the projection.
If you are still balancing this ORM choice against integration style and service boundaries, this app architecture overview helps place the decision in a broader production context.
Minimal docker-compose.yml for Local PostgreSQL
version: "3.8"
services:
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: app
ports: ["5432:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d app"]
interval: 5s
timeout: 3s
retries: 5