Go ORM · Zero driver deps · Compile-once

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.

2000
Compiled plan cache (default)
0
Driver dependencies — bring your own
4
SQL dialects supported

Quickstart

BreezeORM wraps any *sql.DB. Import a driver separately — the ORM itself has zero driver dependencies by design.

go.mod
require github.com/nelthaarion/breezeorm v0.1.0
  1. Define a model with struct tags

    Tags drive the metadata compiler — table name, primary key, autoincrement, unique indexes, defaults, and validation rules.

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

  3. Query with orm.Model[T](db)

    The generic entry point returns a branching, immutable query builder. Chain Where, OrderBy, Limit, and terminate with Find, First, Create, etc.

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

01
Builder
Immutable fluent AST
02
PreHash → Cache
Structural key, skip compile on hit
03
Planner
Logical → physical plan
04
Optimizer
Predicate push-down, reorder
05
SQL gen
Dialect-aware text + args
06
Execute
Prepared stmt cache
07
Scanner
Offset-based, zero reflect
💡
Compile once, execute forever

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.

Branching is safe and free
// 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

orm.Open
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()
OptionDefaultDescription
WithCompiledQueryCacheSize(n)2000Max distinct query shapes in the compiled-plan cache. Size for unique shapes, not requests.
WithScanPlanCacheSize(n)2000Max 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.

Predicates
// 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")
OpSQL
OpEq= $1
OpNeq<> $1
OpLt / OpLte / OpGt / OpGte< <= > >=
OpLike / OpILikeLIKE / ILIKE $1
OpIn / OpNotInIN ($1, $2…)
OpIsNull / OpIsNotNullIS NULL / IS NOT NULL
OpBetweenBETWEEN $1 AND $2
OpRawLiteral SQL fragment

Read operations

READ Find(ctx) ([]T, error)

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.

READ First(ctx) (*T, error)

Implicitly appends LIMIT 1 and returns a single row. Returns "orm: no rows found" when nothing matches — never panics on empty results.

READ Count(ctx) (int64, error)

Rewrites the projection to COUNT(*) and returns the count. Respects the current Where clause.

READ Exists(ctx) (bool, error)

Returns whether any row matches. Implemented as Limit(1).Count(ctx) > 0.

Examples
// 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

WRITE Create(ctx, *T) error

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.

WRITE CreateBatch(ctx, []T) (int64, error)

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.

WRITE UpdateAll(ctx, ...Assignment) (int64, error)

Updates every row matching the current Where clause. Returns the count of affected rows.

DELETE Delete(ctx) (int64, error)

Deletes every row matching the current Where clause. When a SoftDelete plugin is registered, this rewrites to an UPDATE SET deleted_at = now() instead.

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

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

Preload examples
// 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.

Offset 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)
Cursor pagination
// 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)
Cursor vs. offset

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

transaction.Run
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 fieldDefaultDescription
MaxAttempts3Total attempts including the first.
BaseDelay10msInitial backoff; doubles with each retry.
MaxDelay500msCap on the exponential backoff.
IsRetryablebuilt-in heuristicClassifies 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.

Full tag example
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 optionDescription
pkMarks the primary key column.
autoincrementColumn is auto-generated on insert — excluded from INSERT values.
uniqueMarks the column for a unique index (used in migration diffing).
default=…Default value, applied in migrations and documented in metadata.
generatedServer-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.

Rules
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)
        }
    }
}
RuleDescription
requiredField must be non-zero.
min=NNumeric: value ≥ N. String: length ≥ N characters.
max=NNumeric: value ≤ N. String: length ≤ N characters.
emailValid RFC 5322 email address.
urlValid absolute URL with scheme.
uuidStandard UUID format (xxxxxxxx-xxxx-…).
regex=patternMust match the compiled regex. Pattern is cached.
custom=nameCalls 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.

Hook interfaces
// 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.

Custom plugin
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{},
    ),
)
⚠️
Request-scoped plugins bypass the plan cache

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.

migrations/main.go
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.

Postgres
MySQL / MariaDB
SQLite
SQL Server
Dialect selection
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
DialectPlaceholderUpsertRETURNINGLock
Postgres$1, $2ON CONFLICT DO UPDATEFOR UPDATE / SHARE
MySQL?ON DUPLICATE KEY UPDATEFOR UPDATE
SQLite?INSERT OR REPLACE
SQLServer@p1MERGEOUTPUTWITH (UPDLOCK)

Caching & performance

BreezeORM has three caching layers stacked in the hot path:

CacheKeyWhat it storesDefault size
Compiled query cachePreHash(builder, dialect)Full logical + optimized + physical plan2000 shards
Scan plan cachecq.CacheKeyColumn→struct-field offset mapping2000 shards
Prepared stmt cacheSQL text*sql.Stmt handlesConfigurable

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.

💡
GetOrCompute prevents thundering-herd

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.

pkg/scanner/gen/user_scan.go (generated)
// 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

PackageRole
pkg/ormPublic API — DB, Query[T], Model[T]. The only package most applications ever import.
pkg/queryImmutable builder AST — Builder[T], Expr, predicates, joins, CTEs.
pkg/metadataCompiles struct tags into Table (columns, offsets, relations). Called once per type.
pkg/compilerLogical → physical plan compilation. PreHash for cache keying.
pkg/plannerLogical plan nodes (NodeScan, NodeFilter, NodeLimit, …) and physical plan.
pkg/optimizerRewrite passes over the logical plan: predicate push-down, join reorder, limit push-down.
pkg/executionSQL generation, prepared-statement pool, bulk insert, retry, plugin hooks.
pkg/scannerOffset-based row scanner, Plan compiler, fast-scan registry.
pkg/cacheSharded LRU with GetOrCompute for coalesced first-time compilation.
pkg/dialectPostgres, MySQL, SQLite, SQL Server — quoting, placeholders, upsert, locking.
pkg/transactionContext-aware transactions, nested savepoints, exponential-backoff retry.
pkg/pluginsPlugin interface, Chain, NoopPlugin, built-in SoftDelete.
pkg/hooksLifecycle hook interfaces — BeforeCreate, AfterCreate, BeforeSave, etc.
pkg/validationStruct-tag validation engine with cached regex and custom validators.
pkg/relationsHasOne, HasMany, BelongsTo, ManyToMany loaders for preload.
pkg/migrationsVersion-table migration engine with Up/Down/Seed and automatic retry.
pkg/poolGeneric object pool for internal buffer reuse.
pkg/xtypesExtra types — UnixTime for Unix-timestamp columns.