BreezeORM
A fast, generic, strongly-typed ORM for Go that compiles query plans once and executes them forever — built for high-throughput services where every allocation counts.
Quickstart
BreezeORM wraps any *sql.DB. Import a driver separately — the ORM itself has zero driver dependencies by design.
require github.com/nelthaarion/breezeorm v0.1.0
-
Define a model with struct tags
Tags drive the metadata compiler — table name, primary key, autoincrement, unique indexes, defaults, and validation rules.
-
Open a DB handle
Call
orm.Open(sqlDB, dialect.Postgres{}). Pass your existing*sql.DB— BreezeORM is a thin layer on top of the standard library. -
Query with
orm.Model[T](db)The generic entry point returns a branching, immutable query builder. Chain
Where,OrderBy,Limit, and terminate withFind,First,Create, etc.
import (
"database/sql"
"context"
_ "github.com/lib/pq" // your driver
"github.com/nelthaarion/breezeorm/pkg/orm"
"github.com/nelthaarion/breezeorm/pkg/dialect"
"github.com/nelthaarion/breezeorm/pkg/query"
)
// 1 · Model definition
type User struct {
ID int64 `db:"id,pk,autoincrement"`
Email string `db:"email,unique" validate:"required,email"`
Name string `db:"name" validate:"required,min=2,max=100"`
Active bool `db:"active,default=true"`
CreatedAt time.Time `db:"created_at"`
}
func main() {
// 2 · Open
sqlDB, _ := sql.Open("postgres", "postgres://user:pass@localhost/mydb")
db := orm.Open(sqlDB, dialect.Postgres{})
defer db.Close()
ctx := context.Background()
// 3 · Query
users, err := orm.Model[User](db).
Where(query.Predicate{Column: "active", Op: query.OpEq, Value: true}).
OrderBy(query.OrderTerm{Column: "created_at", Desc: true}).
Limit(20).
Find(ctx)
// 4 · Insert
u := &User{Email: "alice@example.com", Name: "Alice", Active: true}
err = orm.Model[User](db).Create(ctx, u)
}
How it works
Every call to Find, First, Create, etc. runs the same pipeline. The key insight: the compile stage runs only once per unique query shape and the result is cached in a sharded LRU.
Steps 03–05 are expensive. BreezeORM skips them on every call for a seen query shape — only args change. The plan cache is sharded to avoid lock contention under concurrent load.
pkg/orm
Public API surface. DB, Query[T], Model[T] — the only package most users ever import.
pkg/compiler
Runs the logical → physical pipeline. PreHash produces the structural cache key in one pass over the builder.
pkg/execution
Prepared statement pool, bulk insert, SQL generation, retry on transient errors.
pkg/scanner
Maps result columns to struct fields via precomputed byte offsets — no reflect.FieldByIndex on the hot path.
pkg/cache
Sharded LRU for compiled plans and scan plans. GetOrCompute coalesces concurrent first-time compiles.
pkg/dialect
Postgres, MySQL, SQLite, SQL Server — one interface, four implementations. Swap at orm.Open time.
Model & Query
orm.Model[T](db) is the entry point for every operation. It returns a *Query[T] — an immutable, branchable query builder backed by your model's compiled metadata.
// base is a reusable query — its internal builder is a value type,
// so branches never share mutable state.
base := orm.Model[User](db).
Where(query.Predicate{Column: "active", Op: query.OpEq, Value: true})
admins := base.Where(query.Predicate{Column: "role", Op: query.OpEq, Value: "admin"})
recent := base.OrderBy(query.OrderTerm{Column: "created_at", Desc: true}).Limit(10)
// base is untouched; admins and recent are independent queries
Opening a DB
db := orm.Open(
sqlDB,
dialect.Postgres{},
orm.WithPlugins(plugins.NewSoftDelete("deleted_at")),
orm.WithCompiledQueryCacheSize(4000),
orm.WithScanPlanCacheSize(4000),
orm.WithExecutorOptions(
execution.WithStmtCacheSize(512),
execution.WithDefaultTimeout(5*time.Second),
),
)
defer db.Close()
| Option | Default | Description |
|---|---|---|
WithCompiledQueryCacheSize(n) | 2000 | Max distinct query shapes in the compiled-plan cache. Size for unique shapes, not requests. |
WithScanPlanCacheSize(n) | 2000 | Max cached scan plans (column→field mappings). |
WithPlugins(...) | — | Register plugins that run on every query (SoftDelete, Tracing, etc.). |
WithOptimizerPasses(...) | DefaultPipeline() | Override the logical plan optimization stages. |
WithExecutorOptions(...) | — | Tune prepared-statement cache size, default timeout, retry policy. |
Where & Expressions
All predicates are typed AST nodes — no string interpolation, no SQL injection surface. Multiple Where calls are AND-ed together automatically.
// Comparison operators
query.Predicate{Column: "age", Op: query.OpGte, Value: 18}
query.Predicate{Column: "name", Op: query.OpLike, Value: "%alice%"}
query.Predicate{Column: "email", Op: query.OpILike, Value: "%@example.com"}
query.Predicate{Column: "role", Op: query.OpIn, Value: []any{"admin", "mod"}}
query.Predicate{Column: "deleted", Op: query.OpIsNull}
query.Predicate{Column: "score", Op: query.OpBetween, Value: [2]any{50, 100}}
// Logical combinators
query.And(pred1, pred2, pred3)
query.Or(pred1, pred2)
query.Not(pred1)
// Raw SQL escape hatch (named params)
query.Raw("score > :min AND category = :cat", "min", 80, "cat", "premium")
| Op | SQL |
|---|---|
OpEq | = $1 |
OpNeq | <> $1 |
OpLt / OpLte / OpGt / OpGte | < <= > >= |
OpLike / OpILike | LIKE / ILIKE $1 |
OpIn / OpNotIn | IN ($1, $2…) |
OpIsNull / OpIsNotNull | IS NULL / IS NOT NULL |
OpBetween | BETWEEN $1 AND $2 |
OpRaw | Literal SQL fragment |
Read operations
Executes the query and returns all matching rows. When a generated fast-scan func is registered for this model, Find dispatches to it — skipping the reflection path entirely.
Implicitly appends LIMIT 1 and returns a single row. Returns "orm: no rows found" when nothing matches — never panics on empty results.
Rewrites the projection to COUNT(*) and returns the count. Respects the current Where clause.
Returns whether any row matches. Implemented as Limit(1).Count(ctx) > 0.
// All active users, newest first
users, err := orm.Model[User](db).
Where(query.Predicate{Column: "active", Op: query.OpEq, Value: true}).
OrderBy(query.OrderTerm{Column: "created_at", Desc: true}).
Find(ctx)
// Single row by ID
user, err := orm.Model[User](db).
Where(query.Predicate{Column: "id", Op: query.OpEq, Value: 42}).
First(ctx)
// Count admins
n, err := orm.Model[User](db).
Where(query.Predicate{Column: "role", Op: query.OpEq, Value: "admin"}).
Count(ctx)
// Select specific columns only
users, err := orm.Model[User](db).
Select(query.SelectExpr{Expr: "id"}, query.SelectExpr{Expr: "email"}).
Find(ctx)
// DISTINCT rows
users, err := orm.Model[User](db).Distinct().Find(ctx)
// Pessimistic lock (SELECT … FOR UPDATE)
user, err := orm.Model[User](db).
Where(query.Predicate{Column: "id", Op: query.OpEq, Value: 1}).
Lock(dialect.LockForUpdate).
First(ctx)
Write operations
Inserts one row. Fires BeforeCreate / AfterCreate hooks if the model implements them. Populates auto-generated fields (e.g. id) back onto the struct when the dialect supports RETURNING.
Inserts many rows using multi-row INSERT statements. Input larger than the max bulk size is automatically chunked, all within a single transaction. Returns the total number of affected rows.
Updates every row matching the current Where clause. Returns the count of affected rows.
Deletes every row matching the current Where clause. When a SoftDelete plugin is registered, this rewrites to an UPDATE SET deleted_at = now() instead.
// Single insert
u := &User{Email: "bob@example.com", Name: "Bob"}
err := orm.Model[User](db).Create(ctx, u)
// Batch insert — one transaction, multi-row VALUES
users := []User{{Name: "Alice"}, {Name: "Bob"}, {Name: "Carol"}}
n, err := orm.Model[User](db).CreateBatch(ctx, users)
// Update — target specific rows
n, err := orm.Model[User](db).
Where(query.Predicate{Column: "id", Op: query.OpEq, Value: 42}).
UpdateAll(ctx,
query.Assignment{Column: "name", Value: "Alice Updated"},
query.Assignment{Column: "active", Value: false},
)
// Delete
n, err := orm.Model[User](db).
Where(query.Predicate{Column: "active", Op: query.OpEq, Value: false}).
Delete(ctx)
Joins
All join types are supported. The condition is an Expr, so you get the same type-safe predicate system as Where.
q := orm.Model[User](db)
// INNER JOIN
q = q.InnerJoin("orders", "o",
query.Predicate{Column: "o.user_id", Op: query.OpEq, Value: "users.id"})
// LEFT JOIN
q = q.LeftJoin("profiles", "p",
query.Predicate{Column: "p.user_id", Op: query.OpEq, Value: "users.id"})
// RIGHT / FULL / CROSS
q = q.RightJoin("departments", "d", on)
q = q.FullJoin("audit_log", "a", on)
q = q.CrossJoin("tags", "t")
Preload (relations)
BreezeORM supports eager loading via Preload. Nested paths use dot notation. Conditional preloads scope the related query with a Where clause. Batch mode avoids N+1 with a single WHERE IN query.
// Simple: load User.Posts
users, err := orm.Model[User](db).
Preload("Posts").
Find(ctx)
// Nested: User.Posts.Comments
users, err := orm.Model[User](db).
Preload("Posts.Comments").
Find(ctx)
// Conditional: only published posts
users, err := orm.Model[User](db).
Preload("Posts",
query.WithPreloadWhere(
query.Predicate{Column: "published", Op: query.OpEq, Value: true},
),
query.WithPreloadLimit(10),
).
Find(ctx)
// Batch mode: single WHERE IN instead of per-row query
users, err := orm.Model[User](db).
Preload("Posts", query.WithBatchPreload()).
Find(ctx)
Pagination
Two pagination strategies: classic offset-based and cursor-based keyset pagination.
// Page 3, 20 items per page — equivalent to LIMIT 20 OFFSET 40
users, err := orm.Model[User](db).
OrderBy(query.OrderTerm{Column: "id"}).
Page(3, 20).
Find(ctx)
// Or manually
users, err := orm.Model[User](db).
Limit(20).Offset(40).Find(ctx)
// After(cursor) translates to a keyset WHERE clause
// based on the current OrderBy terms — stable under inserts.
users, err := orm.Model[User](db).
OrderBy(query.OrderTerm{Column: "created_at", Desc: true}).
Limit(20).
After(lastSeenCursor).
Find(ctx)
Prefer cursor pagination for feeds and real-time data. Offset pagination skips rows when new items are inserted during traversal. Cursor pagination is stable because it uses a WHERE id > last_id keyset predicate.
Transactions
BreezeORM provides context-aware transactions with nested savepoint support and automatic retry on transient failures (deadlocks, serialization errors, SQLite busy).
err := transaction.Run(ctx, db.SQLDB(), nil, transaction.DefaultRetryPolicy(),
func(txCtx context.Context) error {
// Any ORM call here reuses the transaction from txCtx.
if err := orm.Model[Order](db).Create(txCtx, order); err != nil {
return err // triggers automatic rollback
}
_, err := orm.Model[Inventory](db).
Where(query.Predicate{Column: "sku", Op: query.OpEq, Value: order.SKU}).
UpdateAll(txCtx, query.Assignment{Column: "stock", Value: newStock})
return err
})
// Nested transaction — becomes a SAVEPOINT automatically
err = transaction.Run(txCtx, db.SQLDB(), nil, transaction.DefaultRetryPolicy(),
func(innerCtx context.Context) error {
// Runs as SAVEPOINT sp_1 inside the outer transaction
return orm.Model[AuditLog](db).Create(innerCtx, log)
})
| RetryPolicy field | Default | Description |
|---|---|---|
MaxAttempts | 3 | Total attempts including the first. |
BaseDelay | 10ms | Initial backoff; doubles with each retry. |
MaxDelay | 500ms | Cap on the exponential backoff. |
IsRetryable | built-in heuristic | Classifies an error as transient. Matches deadlock, serialization failure, lock wait timeout, SQLite busy across all dialects. |
Struct tags
The db tag drives the metadata compiler. The validate tag is consumed by the validation engine, optionally wired through BeforeSave.
type Product struct {
ID int64 `db:"id,pk,autoincrement"`
SKU string `db:"sku,unique" validate:"required"`
Name string `db:"name" validate:"required,min=2,max=200"`
Price float64 `db:"price" validate:"min=0"`
Category string `db:"category" validate:"required"`
InStock bool `db:"in_stock,default=true"`
Description string `db:"description" validate:"max=2000"`
CreatedAt time.Time `db:"created_at,generated"`
UpdatedAt time.Time `db:"updated_at,generated"`
DeletedAt *time.Time `db:"deleted_at"` // soft-delete column
}
| Tag option | Description |
|---|---|
pk | Marks the primary key column. |
autoincrement | Column is auto-generated on insert — excluded from INSERT values. |
unique | Marks the column for a unique index (used in migration diffing). |
default=… | Default value, applied in migrations and documented in metadata. |
generated | Server-generated column (e.g. created_at DEFAULT NOW()) — excluded from INSERT/UPDATE. |
- | Excludes the field from all ORM operations. |
Validation
Tag-driven field validation. Call validation.Validate(model) directly, or wire it into BeforeSave / BeforeCreate hooks.
type User struct {
Email string `validate:"required,email"`
Name string `validate:"required,min=2,max=100"`
Age int `validate:"min=0,max=150"`
Website string `validate:"url"`
UUID string `validate:"uuid"`
Slug string `validate:"regex=^[a-z0-9-]+$"`
Category string `validate:"custom=allowed_category"`
}
// Register a custom validator
validation.RegisterCustom("allowed_category", func(v any) error {
allowed := map[string]bool{"tech": true, "finance": true}
if !allowed[fmt.Sprint(v)] {
return fmt.Errorf("invalid category")
}
return nil
})
// Validate returns *ValidationError with a map of field→error
if err := validation.Validate(&user); err != nil {
var ve *validation.ValidationError
if errors.As(err, &ve) {
for field, e := range ve.Fields {
fmt.Printf("%s: %v\n", field, e)
}
}
}
| Rule | Description |
|---|---|
required | Field must be non-zero. |
min=N | Numeric: value ≥ N. String: length ≥ N characters. |
max=N | Numeric: value ≤ N. String: length ≤ N characters. |
email | Valid RFC 5322 email address. |
url | Valid absolute URL with scheme. |
uuid | Standard UUID format (xxxxxxxx-xxxx-…). |
regex=pattern | Must match the compiled regex. Pattern is cached. |
custom=name | Calls a function registered with validation.RegisterCustom. |
Lifecycle hooks
Implement any hook interface on your model struct. BreezeORM checks for them via a single type assertion — zero cost when the model doesn't implement a hook.
// Implement any combination of these on your model:
type BeforeCreate interface { BeforeCreate(ctx context.Context) error }
type AfterCreate interface { AfterCreate(ctx context.Context) error }
type BeforeUpdate interface { BeforeUpdate(ctx context.Context) error }
type AfterUpdate interface { AfterUpdate(ctx context.Context) error }
type BeforeDelete interface { BeforeDelete(ctx context.Context) error }
type AfterDelete interface { AfterDelete(ctx context.Context) error }
type BeforeSave interface { BeforeSave(ctx context.Context) error }
type AfterSave interface { AfterSave(ctx context.Context) error }
// Example: hash password before create
func (u *User) BeforeCreate(ctx context.Context) error {
hashed, err := bcrypt.GenerateFromPassword([]byte(u.Password), 14)
if err != nil { return err }
u.Password = string(hashed)
u.CreatedAt = time.Now()
return validation.Validate(u) // wire validation here
}
Plugins
Plugins hook into query compilation and execution. When no plugins are registered, the cost is a single len(chain) == 0 check — zero overhead on the benchmark path.
SoftDelete
Injects WHERE deleted_at IS NULL into every query automatically. Delete rewrites to UPDATE SET deleted_at = now().
MultiTenancy
Per-request plugin that injects a tenant predicate. Plan cache is bypassed for request-scoped plugins — no cross-tenant data leaks.
Metrics / Tracing
BeforeExecute / AfterExecute hooks give you SQL text, args, and wall-clock duration for every query. Wire OpenTelemetry here.
Auditing
Observe every write. AfterExecute receives the SQL, args, duration, and error — enough to build a complete audit trail.
type QueryLogger struct{ plugins.NoopPlugin }
func (QueryLogger) Name() string { return "query_logger" }
func (QueryLogger) AfterExecute(_ context.Context, sql string, ns int64, err error) {
log.Printf("[sql] %s | %dµs | err=%v", sql, ns/1000, err)
}
db := orm.Open(sqlDB, dialect.Postgres{},
orm.WithPlugins(
plugins.NewSoftDelete("deleted_at"),
QueryLogger{},
),
)
Plugins that implement IsRequestScoped() bool returning true cause compileCached to skip the compiled-plan cache entirely. This is correct and intentional — a plan baked with one tenant's predicate must never be served to another. Only implement this for plugins whose BeforePlan output changes per-request.
Migrations
BreezeORM ships a version-table based migration engine. Migrations are Go functions — no DSL files, no external tooling required.
m := migrations.New(sqlDB, "schema_migrations")
m.Register(
migrations.Migration{
Version: "20260701000000",
Name: "create_users",
Up: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
return err
},
Down: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS users")
return err
},
Seed: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,
"INSERT INTO users (email, name) VALUES ($1, $2)",
"admin@example.com", "Admin",
)
return err
},
},
)
// Apply all pending migrations
err := m.Up(ctx)
// Roll back the most recent migration
err = m.Down(ctx)
Dialects
Pass the dialect to orm.Open. The dialect handles quoting, placeholder syntax, upsert syntax, and lock modes.
orm.Open(sqlDB, dialect.Postgres{}) // $1 placeholders, RETURNING, ILIKE
orm.Open(sqlDB, dialect.MySQL{}) // ? placeholders, ON DUPLICATE KEY
orm.Open(sqlDB, dialect.SQLite{}) // ? placeholders, WAL-friendly
orm.Open(sqlDB, dialect.SQLServer{}) // @p1 placeholders, TOP N syntax
| Dialect | Placeholder | Upsert | RETURNING | Lock |
|---|---|---|---|---|
Postgres | $1, $2 | ON CONFLICT DO UPDATE | ✓ | FOR UPDATE / SHARE |
MySQL | ? | ON DUPLICATE KEY UPDATE | — | FOR UPDATE |
SQLite | ? | INSERT OR REPLACE | — | — |
SQLServer | @p1 | MERGE | OUTPUT | WITH (UPDLOCK) |
Caching & performance
BreezeORM has three caching layers stacked in the hot path:
| Cache | Key | What it stores | Default size |
|---|---|---|---|
| Compiled query cache | PreHash(builder, dialect) | Full logical + optimized + physical plan | 2000 shards |
| Scan plan cache | cq.CacheKey | Column→struct-field offset mapping | 2000 shards |
| Prepared stmt cache | SQL text | *sql.Stmt handles | Configurable |
How PreHash works
PreHash computes a structural key from the builder in a single pass — before the planner or optimizer run. It captures the query shape (which tables, which predicates, which columns, limit/offset presence) but not the literal values. Two queries with the same shape but different WHERE id = ? values produce the same hash and share a compiled plan.
When N goroutines hit a cold cache for the same query shape simultaneously, GetOrCompute coalesces them — only one goroutine runs the compile pipeline. The others wait and receive the same result. Without this, each goroutine would compile independently and waste CPU.
Sharded LRU
Both plan caches are sharded. PreHash is already a well-distributed uint64, so shard selection is a single modulo — no re-hashing of query text. Under concurrent load, goroutines working on different query shapes never contend on the same mutex.
Scanner performance
The scanner maps result columns to struct fields using precomputed byte offsets from metadata.Compile. On the hot path it does a pointer add per column — no reflect.FieldByIndex, no map lookup. With pkg/scanner/gen, you can generate a typed FastScanFunc[T] that writes directly into rows.Scan pointers, matching hand-written sqlx performance.
Scanner & code generation
The default scanner is reflection-free (offset arithmetic). For maximum throughput, you can generate a typed scan function that goes one step further — no type switch, no Plan lookup, direct field pointers.
// This file is generated — do not edit by hand.
func ScanUser(rows scanner.Rows, dest *User) error {
return rows.Scan(&dest.ID, &dest.Email, &dest.Name, &dest.Active, &dest.CreatedAt)
}
func init() {
scanner.RegisterFastScan[User](queryCacheKey, ScanUser)
}
Once registered, Find and First automatically dispatch to the generated function when the cache key matches. The registration check is a map lookup — no reflection, no type switch at all.
Package map
| Package | Role |
|---|---|
pkg/orm | Public API — DB, Query[T], Model[T]. The only package most applications ever import. |
pkg/query | Immutable builder AST — Builder[T], Expr, predicates, joins, CTEs. |
pkg/metadata | Compiles struct tags into Table (columns, offsets, relations). Called once per type. |
pkg/compiler | Logical → physical plan compilation. PreHash for cache keying. |
pkg/planner | Logical plan nodes (NodeScan, NodeFilter, NodeLimit, …) and physical plan. |
pkg/optimizer | Rewrite passes over the logical plan: predicate push-down, join reorder, limit push-down. |
pkg/execution | SQL generation, prepared-statement pool, bulk insert, retry, plugin hooks. |
pkg/scanner | Offset-based row scanner, Plan compiler, fast-scan registry. |
pkg/cache | Sharded LRU with GetOrCompute for coalesced first-time compilation. |
pkg/dialect | Postgres, MySQL, SQLite, SQL Server — quoting, placeholders, upsert, locking. |
pkg/transaction | Context-aware transactions, nested savepoints, exponential-backoff retry. |
pkg/plugins | Plugin interface, Chain, NoopPlugin, built-in SoftDelete. |
pkg/hooks | Lifecycle hook interfaces — BeforeCreate, AfterCreate, BeforeSave, etc. |
pkg/validation | Struct-tag validation engine with cached regex and custom validators. |
pkg/relations | HasOne, HasMany, BelongsTo, ManyToMany loaders for preload. |
pkg/migrations | Version-table migration engine with Up/Down/Seed and automatic retry. |
pkg/pool | Generic object pool for internal buffer reuse. |
pkg/xtypes | Extra types — UnixTime for Unix-timestamp columns. |