Hanzo ORM
Generics-based ORM for Go with type-safe Model[T], auto-registration, auto-serialization, and multi-backend support.
Hanzo ORM
Hanzo ORM is a generics-based object-relational mapper for Go that eliminates model boilerplate through a single Model[T] mixin. Define your struct, register it, and get type-safe CRUD, queries, lifecycle hooks, auto-serialization, and caching -- all with zero code generation.
Module: github.com/hanzoai/orm
Version: v0.3.0
Go: 1.22+
License: MIT
Install
go get github.com/hanzoai/orm@latestQuick Start
Define a Model
package main
import "github.com/hanzoai/orm"
type User struct {
orm.Model[User]
Name string `json:"name"`
Email string `json:"email"`
Status string `json:"status" orm:"default:active"`
}
func init() {
orm.Register[User]("user")
}Embedding orm.Model[User] gives your struct an auto-generated ID, kind name, and all CRUD methods. The init() call registers metadata at startup -- no code generation step.
Connect to a Backend
import ormdb "github.com/hanzoai/orm/db"
// SQLite (embedded, zero config)
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: "data/app.db",
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
// Or ZAP binary protocol (PostgreSQL, MongoDB, Redis, ClickHouse)
db, err := orm.OpenZap(&ormdb.ZapConfig{
Addr: "localhost:9651",
Backend: ormdb.ZapSQL,
})
if err != nil {
log.Fatal(err)
}Create
user := orm.New[User](db)
user.Name = "Alice"
user.Email = "alice@example.com"
// Status defaults to "active" via orm:"default:active"
if err := user.Create(); err != nil {
log.Fatal(err)
}
fmt.Println(user.Id()) // unique nanosecond-precision IDRead
got, err := orm.Get[User](db, user.Id())
if err != nil {
log.Fatal(err)
}
fmt.Println(got.Name) // "Alice"Update
got.Name = "Alice Smith"
if err := got.Update(); err != nil {
log.Fatal(err)
}Delete
if err := got.Delete(); err != nil {
log.Fatal(err)
}Query
q := orm.TypedQuery[User](db)
active, err := q.Filter("Status=", "active").Get()
if err != nil {
log.Fatal(err)
}
for _, u := range active {
fmt.Println(u.Name, u.Email)
}Architecture
┌─────────────────────────────────────────────────────┐
│ Your Application │
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Model[User]│ │Model[Order]│ │Model[Product]│ │
│ └─────┬──────┘ └─────┬──────┘ └──────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ orm.DB Interface │ │
│ │ New[T] · Get[T] · TypedQuery[T] │ │
│ │ Register · Hooks · Defaults · Serialize │ │
│ └─────────────────────┬───────────────────────┘ │
│ │ │
│ ┌─────────────────────┼───────────────────────┐ │
│ │ Cache Layer (optional) │ │
│ │ Memory LRU · Redis/Valkey · None │ │
│ └─────────────────────┬───────────────────────┘ │
│ │ │
│ ┌─────────┬───────────┼──────────┬────────────┐ │
│ │ SQLite │ ZAP Binary Protocol │ Custom │ │
│ │ (WAL) │ PostgreSQL│ MongoDB │ Driver │ │
│ │ │ Redis │ClickHouse│ │ │
│ └─────────┴───────────┴──────────┴────────────┘ │
└─────────────────────────────────────────────────────┘Packages
| Package | Import | Description |
|---|---|---|
| orm | github.com/hanzoai/orm | Core Model[T], registration, hooks, cache, serialization |
| orm/db | github.com/hanzoai/orm/db | Database interfaces + SQLite driver |
| orm/val | github.com/hanzoai/orm/val | Struct field validation |
| orm/internal/json | github.com/hanzoai/orm/internal/json | JSON encode/decode helpers |
| orm/internal/reflect | github.com/hanzoai/orm/internal/reflect | Reflection utilities |
Key Features
| Feature | Description |
|---|---|
| Generics Model | Model[T] mixin -- embed once, get Id, Kind, CRUD, Query |
| Auto-Registration | Register[T]("kind") in init() -- no code generation |
| Auto-Serialization | orm:"serialize" tag auto-marshals complex fields to JSON strings |
| Struct Tag Defaults | orm:"default:value" sets field defaults on creation |
| Lifecycle Hooks | BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate interfaces |
| Typed Queries | TypedQuery[T] returns []*T -- no type assertions |
| KV Cache | Read-through caching with Memory LRU, Redis/Valkey, or no-op backends |
| SQLite Driver | WAL mode, JSON storage, json_extract queries, sqlite-vec ready |
| ZAP Driver | Binary protocol to PostgreSQL, MongoDB, Redis, ClickHouse via zap-sidecar |
| Namespace Support | Multi-tenant isolation with SetNamespace |
| Context Propagation | CreateCtx, UpdateCtx, DeleteCtx for deadline/tracing support |
| Old-Entity Hooks | BeforeUpdate(old *T) receives the previous state for diff-aware logic |
| Must Variants | MustGet, MustNew -- panic on error for scripts and tests |
| GetOrCreate / GetOrUpdate | Atomic find-or-initialize and find-or-modify helpers |
| Query.First / GetAll / Count | Single-result, context-aware list, and count queries |
New in v0.3.0
Namespace Support
Namespace scoping enables multi-tenant isolation without separate tables or databases. All CRUD and query operations are automatically scoped to the active namespace.
user := orm.New[User](db)
user.SetNamespace("org-123") // all ops scoped to org-123
user.Name = "Alice"
user.Create() // stored under namespace "org-123"
// Queries are scoped too
q := orm.TypedQuery[User](db)
q.SetNamespace("org-123")
users, _ := q.Filter("Status=", "active").Get()Context Propagation
All mutating operations now accept a context.Context for deadline enforcement, cancellation, and tracing propagation.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
user := orm.New[User](db)
user.Name = "Alice"
if err := user.CreateCtx(ctx); err != nil {
log.Fatal(err)
}
user.Name = "Alice Smith"
if err := user.UpdateCtx(ctx); err != nil {
log.Fatal(err)
}
if err := user.DeleteCtx(ctx); err != nil {
log.Fatal(err)
}The original Create(), Update(), and Delete() methods still work and use context.Background() internally.
Old-Entity Hooks
BeforeUpdate and AfterUpdate hooks now receive the previous entity state, enabling diff-aware logic like audit logging, conditional side effects, and change detection.
func (u *User) BeforeUpdate(old *User) error {
if old.Email != u.Email {
// email changed -- send verification
return sendVerificationEmail(u.Email)
}
return nil
}
func (u *User) AfterUpdate(old *User) error {
if old.Status != u.Status {
// status changed -- emit event
emitStatusChange(u.Id(), old.Status, u.Status)
}
return nil
}Must Variants and Helpers
Must* functions panic on error -- useful for tests, scripts, and initialization code where failure is not recoverable.
// Panics if user not found
user := orm.MustGet[User](db, "user-123")
// GetOrCreate: returns existing entity or creates a new one
entity, created, err := orm.GetOrCreate[User](db, "user-123", func(u *User) {
u.Name = "Default"
u.Status = "pending"
})
// created == true if a new entity was initialized
// GetOrUpdate: fetches then applies a mutation
entity, err := orm.GetOrUpdate[User](db, "user-123", func(u *User) {
u.LastLogin = time.Now()
})CloneFromJSON deserializes JSON into a new entity copy, and Zero returns a zero-valued entity of the registered kind -- both useful for testing and data migration.
clone, err := orm.CloneFromJSON[User](db, jsonBytes)
empty := orm.Zero[User](db)Query Improvements
TypedQuery[T] gains three convenience methods:
q := orm.TypedQuery[User](db)
// First: returns the first match or ErrNotFound
user, err := q.Filter("Email=", "alice@example.com").First()
if errors.Is(err, orm.ErrNotFound) {
// no match
}
// GetAll: context-aware list query
ctx := context.Background()
users, err := q.Filter("Status=", "active").GetAll(ctx)
// Count: returns the number of matching entities
count, err := q.Filter("Status=", "active").Count(ctx)Related Services
Serverless PostgreSQL for relational data and embeddings
Redis/Valkey for caching, sessions, and pub/sub
Billing platform built on Hanzo ORM
Go client for the Hanzo LLM Gateway
How is this guide?
Last updated on