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.

golang + postgresql

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 selective Preload).
  • 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-row INSERT) 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

ORM packages and libs in GO