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. 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

ORM packages and libs in GO

Subscribe

Get new posts on AI systems, Infrastructure, and AI engineering.