Skip to main content
Version: v1.11.0

Quark vs Other Go ORMs

This page justifies every cell in the comparison table with code examples and precise reasoning. The goal is not to disparage other projects but to articulate clearly where the trade-offs lie.

The canonical version of this comparison is in docs/comparison.md in the main repository.

Summary table

Compared against GORM v1.30 (the first release with the generics API), sqlx v1.4 and ent v0.14, as of 2026-09.

QuarkGORMsqlxEnt
Native Generics (no interface{})✅ since v1.30¹
Compile-time column accessors✅ (opt-in codegen)✅ (required codegen)
SQL Injection Guardidentifier + valuevalue only²manualvalue only²
6 Dialects, zero config switchpartial
Composable query AST (CTEs, window, set ops, locking)partial (raw)partial
Multi-Tenant RLS — client-side WHERE injection✅ all 6 dialectsmanual/pluginmanualmanual/interceptor
Multi-Tenant RLS — engine-enforced✅ PostgreSQL (set_config(..., true) + CREATE POLICY)
Multi-Tenant — schema-per-tenant / DB-per-tenantmanualmanualmanual
Read replicas + automatic failover✅ (Sticky(ctx) read-your-writes)plugin (dbresolver)manual
Pluggable sharding✅ (ShardRouter / HashShardFunc)plugin (sharding)manual
Immutable Query Buildermutable³N/A
Integrated L2 Cache✅ + stampede protection⁵plugin
Schema-as-code migrations (diff + lock + backfill)❌ (AutoMigrate only)✅ (with Atlas)
Transactional hooks (OnCommit/OnRollback)
Event bus of CRUD (post-commit)✅ synchronous, at-least-oncepartial
Audit log built-in✅ atomic with CRUD txpartial
stdlib *sql.DB — no magic pool✅⁶
OpenTelemetry — traces and metricsplugin (traces only)plugin
Batch Ops (Delete/Upsert/Update)✅⁴partial⁴partial⁴
Automatic dialect detection (MariaDB vs MySQL)✅ (v1.1)
Inbound LISTEN/NOTIFY listener✅ PostgreSQL (v1.1)

¹ GORM 1.30 (2025) added an official generics API — gorm.G[User](db).Where(...).Find(ctx) — alongside the classic interface{}-based one, which the bulk of its documentation and plugin ecosystem still uses. Both are supported; the generic one is opt-in per call. ² GORM and ent use parameterized queries that protect values against injection. Quark additionally validates identifiers (column/table names, operators, JSON paths, JOIN-ON clauses) at the API layer. See SQLGuard for a detailed breakdown. ³ GORM queries can mutate shared state when chained; Session(&gorm.Session{NewDB: true}) mitigates this but is opt-in. ⁴ GORM supports CreateInBatches; batch DELETE and batch UPDATE require custom loops. Ent supports batch create; batch UPDATE/DELETE require raw queries or custom extensions. Quark chunks automatically so a large slice never overruns the driver limit: DeleteBatch has always chunked its IN clause (1000 elements, Oracle's IN ceiling), and since v1.1.0 CreateBatch also chunks by bind-parameter count (capped well under SQL Server's ~2100-parameter limit). ⁵ Quark wraps the cache backing with stampede protection (singleflight plus probabilistic early refresh), so a hot key never produces a database stampede on miss. No competitor in this matrix ships stampede protection out of the box. ⁶ ent runs on database/sql: ent.Driver(entsql.OpenDB(dialect, db)) wraps an existing *sql.DB, and ent.Open calls sql.Open underneath. It does not manage its own pool.


1. Native Generics

Quark

// Fully typed — no interface{} cast, compiler enforces T
users, err := quark.For[User](ctx, client).
Where("active", "=", true).
List()
// users is []User

GORM

// Classic API — Find takes interface{}; a wrong destination type compiles silently
var users []User
result := db.Where("active = ?", true).Find(&users)

// Generics API (v1.30+) — typed at compile time
users, err := gorm.G[User](db).Where("active = ?", true).Find(ctx)

GORM 1.30 added gorm.G[T] as an official entry point next to the classic one. The classic interface{} API remains the default in its documentation and is what most plugins are written against; the generic path is opt-in per call.

sqlx

var users []User
err := db.SelectContext(ctx, &users, "SELECT * FROM users WHERE active = $1", true)
// []User is correct, but the query itself is a raw string

sqlx has no generics-based query builder; all queries are raw SQL strings.

Ent

users, err := client.User.Query().
Where(user.Active(true)).
All(ctx)
// users is []*ent.User — typed, but requires code generation

Ent provides a typed API through generated code. The generation step is a required build dependency.


2. SQL Injection Guard

Quark — identifier + value protection

// Operator is validated at the API layer — never reaches the DB
_, err := quark.For[User](ctx, client).
Where("name", "drop_table", "x").List()
// → invalid query: operator "drop_table" is not allowed (errors.Is quark.ErrInvalidQuery)

// Injection-shaped column identifier is caught before SQL generation
_, err = quark.For[User](ctx, client).
Where(userInput, "=", "value").List() // userInput = "injected--"
// → invalid identifier: identifier "injected--" contains invalid characters
// (errors.Is quark.ErrInvalidIdentifier)

GORM — value protection only

// Value is parameterized — safe
db.Where("name = ?", userInput).Find(&users)

// But ORDER BY identifier is NOT protected
db.Order(userInput).Find(&users) // injection vector if userInput is untrusted

sqlx — fully manual

// Developer is responsible for every identifier that enters the query
query := fmt.Sprintf("SELECT * FROM users ORDER BY %s", sanitize(userInput))
db.SelectContext(ctx, &users, query)

3. Immutable Query Builder

Quark

base := quark.For[User](ctx, client).Where("active", "=", true)

// Each call returns a new clone — base is unchanged
admins, _ := base.Where("role", "=", "admin").List()
editors, _ := base.Where("role", "=", "editor").List()

GORM (mutable)

base := db.Where("active = ?", true)

// Without NewDB session, chained calls mutate shared state
admins := base.Where("role = ?", "admin") // modifies base
editors := base.Where("role = ?", "editor") // may accumulate conditions

The Session(&gorm.Session{NewDB: true}) workaround exists but is opt-in and easy to forget.


4. Native Multi-Tenancy

Quark

cfg := quark.DefaultTenantConfig()
cfg.Strategy = quark.RowLevelSecurityClient
cfg.BaseClient = client

router := quark.NewTenantRouter(cfg, func(ctx context.Context) string {
return ctx.Value("tenant_id").(string)
}, nil)

// Tenant isolation is automatic — no WHERE clause needed
users, _ := quark.For[User](tenantCtx, router).List()

GORM (manual)

// Developer must remember to scope every query
db.Where("tenant_id = ?", tenantID).Find(&users)
// Forgetting this line leaks data across tenants

5. Integrated L2 Cache

Quark

import "github.com/jcsvwinston/quark/cache/memory"

store := memory.New()
client, _ := quark.New("postgres", dsn, quark.WithCacheStore(store))

users, _ := quark.For[User](ctx, client).
Cache(5*time.Minute, "users").
List()

store.InvalidateTags(ctx, "users") // after a write

GORM

GORM has no built-in L2 cache. Community plugins exist but they are external dependencies with separate maintenance.


6. Batch Operations

Quark

// Batch delete — chunked IN clauses, respects dialect limits.
// DeleteBatch takes []any (Go does not auto-convert []int64 to []any).
affected, err := quark.For[User](ctx, client).DeleteBatch([]any{1, 2, 3})
// => affected == 3 (rows 1, 2, 3 deleted)

// Batch upsert — dialect-optimal SQL (ON CONFLICT / ON DUPLICATE KEY / MERGE)
err = quark.For[User](ctx, client).UpsertBatch(users, []string{"email"}, []string{"name"})

// Batch update — N partial updates in a single transaction
err = quark.For[User](ctx, client).UpdateBatch(users)

GORM

// GORM supports CreateInBatches
db.CreateInBatches(users, 100)

// Batch DELETE requires a custom loop or raw SQL
for _, id := range ids {
db.Delete(&User{}, id)
}

Ent

Ent supports bulk create via client.User.CreateBulk(...). Batch UPDATE and DELETE require raw queries or custom extensions.