Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc
A practical, code-heavy look on ORMs in GO
Page content
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/migrate
package 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
Joins
or 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.
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