rezakhademix/zorm has 26 GitHub stars and 5 forks. GitHot measured 0 stars in 24 hours. Last checked 2026-11-15 15:09 UTC. Last push 2026-11-09.
What you can do
- Type-Safe: Full compile-time type safety powered by Go generics
- Zero Dependencies: Built on Go's database/sql package, works with any SQL driver
- High Performance: Prepared statement caching, connection pooling, model pooling — see Advanced Start
- Relations: HasOne, HasMany, BelongsTo, BelongsToMany, Polymorphic relations
Measured star growth
- in 24h
- 0
- in 7 days
- Collecting history
- in 15 days
- Collecting history
- in 1 month
- Collecting history
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
About
Z-ORM A Golang ORM to write fluent and fast queries. Query smarter, Code faster.
Web & AppsREADME.md
OriginalZORM
A Type-Safe, Production Ready Go ORM
One ORM To Query Them All
ZORM is a powerful, type-safe, and developer-friendly Go ORM designed for modern applications. It leverages Go generics to provide compile-time type safety while offering a fluent, chainable API for building complex SQL queries with ease.
Key Features
- Type-Safe: Full compile-time type safety powered by Go generics
- Zero Dependencies: Built on Go's
database/sqlpackage, works with any SQL driver - High Performance: Prepared statement caching, connection pooling, model pooling — see Advanced Start
- Relations: HasOne, HasMany, BelongsTo, BelongsToMany, Polymorphic relations
- Fluent API: Chainable query builder with intuitive method names
- Advanced Queries: CTEs, Subqueries, Full-Text Search, Window Functions
- Database Splitting: Automatic read/write split with replica support — see Advanced Start
- Context Support: All operations respect
context.Contextfor cancellation & timeout - Debugging:
Print()method to inspect generated SQL without executing - Lifecycle Hooks: BeforeCreate / AfterCreate, BeforeUpdate / AfterUpdate, BeforeDelete / AfterDelete, AfterFind — plus
*Txvariants for atomic side effects - Accessors: Computed attributes via getter methods
Installation
go get github.com/rezakhademix/zorm
Quick Start
1. Connect to Database
PostgreSQL
import (
"github.com/rezakhademix/zorm"
)
// Using helper (with connection pooling)
db, err := zorm.ConnectPostgres(
"postgres://user:password@localhost/dbname?sslmode=disable",
&zorm.DBConfig{
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: time.Hour,
ConnMaxIdleTime: 30 * time.Minute,
},
)
zorm.GlobalDB = db
2. Define Models
Models are standard Go structs. ZORM uses convention over configuration - no tags required!
type User struct {
ID int64 // Automatically detected as primary key with auto-increment
Name string // Maps to "name" column
Email string // Maps to "email" column
Age int // Maps to "age" column
CreatedAt time.Time // Maps to "created_at" (auto-set on Create when zero)
UpdatedAt time.Time // Maps to "updated_at" (auto-updated)
}
// Table name: "users" (auto-pluralized snake_case)
Custom Table Name & Primary Key
// Custom table name
func (u User) TableName() string {
return "app_users"
}
// Custom primary key
func (u User) PrimaryKey() string {
return "user_id"
}
3. Basic CRUD
ctx := context.Background()
// Create
user := &User{Name: "John", Email: "[email protected]"}
err := zorm.New[User]().Create(ctx, user)
fmt.Println(user.ID) // Auto-populated after insert
// Read - Single
user, err := zorm.New[User]().Find(ctx, 1)
user, err := zorm.New[User]().Where("email", "[email protected]").First(ctx)
// Read - Multiple
users, err := zorm.New[User]().Where("age", ">", 18).Get(ctx)
// Update
user.Name = "Jane"
err = zorm.New[User]().Update(ctx, user) // updated_at auto-set
// Delete
err = zorm.New[User]().Where("id", 1).Delete(ctx)
4. Bulk Operations
// CreateMany - Insert multiple records in a single query
users := []*User{
{Name: "Alice", Email: "[email protected]"},
{Name: "Bob", Email: "[email protected]"},
{Name: "Charlie", Email: "[email protected]"},
}
err := zorm.New[User]().CreateMany(ctx, users)
// All IDs are auto-populated after insert
fmt.Println(users[0].ID, users[1].ID, users[2].ID)
// UpdateMany - Update multiple records matching query
err = zorm.New[User]().
Where("active", false).
UpdateMany(ctx, map[string]any{"status": "inactive"})
// UpdateManyByKey - Update multiple records by matching lookup column to map keys
// Each map key is matched against the lookup column, and its value is set in the target column
updates := map[string]string{
"REF001": "pending",
"REF002": "approved",
"REF003": "rejected",
}
err = zorm.New[Order]().UpdateManyByKey(ctx, "reference_number", "status", updates)
// DeleteMany - Delete multiple records matching query
err = zorm.New[User]().Where("status", "inactive").DeleteMany(ctx)
CreateMany Features:
- Inserts all records in a single SQL statement for efficiency
- Automatically chunks large batches to stay within database limits (65535 parameters for PostgreSQL)
- Uses transactions for multi-chunk inserts to ensure atomicity
- Returns inserted IDs via
RETURNINGclause - Splits a batch that mixes set and unset primary keys into two INSERTs inside one transaction, so explicit IDs are honored and zero-PK rows are still auto-assigned
- Auto-sets
created_at(when the column exists and the field is zero), but runs no create hooks — useBulkInsertif you needBeforeCreate/AfterCreate
// For very large datasets, CreateMany automatically chunks
largeDataset := make([]*User, 10000)
for i := range largeDataset {
largeDataset[i] = &User{Name: fmt.Sprintf("User %d", i)}
}
err := zorm.New[User]().CreateMany(ctx, largeDataset)
// Automatically split into multiple INSERT statements within a transaction
BulkInsert - Insert many records with the create hooks:
users := []*User{
{Name: "Alice", Email: "[email protected]"},
{Name: "Bob", Email: "[email protected]"},
}
err := zorm.New[User]().BulkInsert(ctx, users)
// BeforeCreate ran for both entities before the first INSERT;
// AfterCreate runs per row as it is inserted; IDs are populated.
BulkInsert reuses one prepared statement across the batch instead of building a
single multi-row INSERT. Pick it over CreateMany when you need hooks; pick
CreateMany when you want the fewest round trips.
CreateMany |
BulkInsert |
|
|---|---|---|
| SQL shape | one multi-row INSERT (chunked) | one prepared statement, executed per row |
created_at auto-set |
yes | yes |
BeforeCreate / AfterCreate |
no | yes |
| Mixed set/unset primary keys | split into two INSERTs in one transaction | inserted as two groups |
BulkInsert runs BeforeCreate for the whole batch before the first row is
written, so a hook may still change the values that get inserted.
UpdateManyByKey - Efficient batch updates using CASE WHEN syntax:
// Example 1: Update order statuses by reference number
statusUpdates := map[string]string{
"ORD-001": "shipped",
"ORD-002": "delivered",
"ORD-003": "cancelled",
}
err := zorm.New[Order]().UpdateManyByKey(ctx, "reference_number", "status", statusUpdates)
// Generates: UPDATE orders SET status = CASE reference_number
// WHEN 'ORD-001' THEN 'shipped' WHEN 'ORD-002' THEN 'delivered' ... END
// WHERE reference_number IN ('ORD-001', 'ORD-002', 'ORD-003')
// Example 2: Update product quantities by product code (int keys, int values)
quantityUpdates := map[int]int{
100: 50, // product code 100 -> quantity 50
200: 75, // product code 200 -> quantity 75
300: 100, // product code 300 -> quantity 100
}
err = zorm.New[Product]().UpdateManyByKey(ctx, "code", "quantity", quantityUpdates)
// Example 3: Combine with WHERE clause for conditional updates
// Only update orders that are in 'pending' status
statusUpdates := map[string]string{
"ORD-001": "processing",
"ORD-002": "processing",
}
err = zorm.New[Order]().
Where("status", "pending").
UpdateManyByKey(ctx, "reference_number", "status", statusUpdates)
// Only updates if both: reference_number matches AND status = 'pending'
UpdateManyByKey Features:
- Uses efficient CASE WHEN syntax (single query for all updates)
- Supports any map key/value types (string, int, float64, bool, etc.)
- Automatically chunks large maps (500+ entries) with transaction safety
- Combines with existing WHERE conditions
- Auto-updates
updated_attimestamp if the column exists.Create/CreateManyalso auto-populatecreated_atwhen present and unset.
Advanced Start
Quick Start gets you querying. This section gets you to production: connection pool sizing, prepared-statement caching, replica routing, and allocation / memory control — in the order you should add them.
1. Tune the connection pool
db, err := zorm.ConnectPostgres(
"postgres://user:[email protected]/app?sslmode=require",
&zorm.DBConfig{
MaxOpenConns: 25, // hard ceiling on concurrent connections
MaxIdleConns: 5, // kept warm between bursts
ConnMaxLifetime: time.Hour, // recycle before the server/proxy does
ConnMaxIdleTime: 30 * time.Minute,
},
)
if err != nil {
log.Fatal(err)
}
zorm.SetGlobalDB(db) // thread-safe; prefer over assigning zorm.GlobalDB directly
ConnectPostgres pings the database before returning, so a bad DSN or an
unreachable host fails at startup rather than on the first query. Passing nil
for the config leaves Go's database/sql defaults in place (unlimited open
connections, 2 idle).
2. Add a statement cache
StmtCache is an LRU of *sql.Stmt shared by every model that opts in. Build
one at startup, close it at shutdown:
cache := zorm.NewStmtCache(200) // capacity <= 0 defaults to 100
defer cache.Close()
// Build a base model once, clone it per query — the cache survives Clone()
base := zorm.New[User]().WithStmtCache(cache)
users, _ := base.Clone().Where("age", ">", 18).Get(ctx)
adults, _ := base.Clone().Where("age", ">", 21).Get(ctx) // same SQL shape, reuses the prepared statement
fmt.Println(cache.Len()) // number of live cached statements
The cache pays off when the same SQL shape repeats — argument values differ, the statement does not. A query built with a different set of clauses is a different statement.
Caching rules worth knowing:
- Keyed per connection handle, not just per SQL string: the key includes the
*sql.DBpointer. One cache can therefore be shared safely across a primary and every replica — a statement prepared against a replica can never be handed to the primary. - Transactions are never cached. A statement prepared on a
*sql.Txdies with its transaction, so ZORM prepares it fresh and closes it at the end of the operation. Workloads that run almost entirely insideTransaction(...)get little benefit from the cache. - Capacity is sharded across independently locked shards (the shard sizes sum to exactly the capacity you asked for), so concurrent lookups on different queries do not contend on one mutex.
Close()closes every cached statement;Clear()empties the cache but keeps it usable.
3. Add replicas
zorm.ConfigureDBResolver(
zorm.WithPrimary(primaryDB),
zorm.WithReplicas(replica1, replica2),
zorm.WithLoadBalancer(zorm.RoundRobinLB), // or zorm.RandomLB
)
// Routing is automatic from here
users, _ := zorm.New[User]().Get(ctx) // -> load-balanced replica
err := zorm.New[User]().Create(ctx, user) // -> always primary
// Manual overrides
users, _ = zorm.New[User]().UsePrimary().Get(ctx) // force primary
users, _ = zorm.New[User]().UseReplica(0).Get(ctx) // force a specific replica
Routing rules worth knowing:
- There is no read-after-write stickiness. A read issued right after a write
goes to a replica and may not see the row yet. Call
UsePrimary()on reads that must observe a just-committed write. - A configured resolver overrides
SetDB(db)for reads and writes — the resolver is consulted first and never falls back to the model's own handle. OnlyWithTx(tx)escapes the resolver: transactions always run on their own connection, and are therefore always on the primary. - Zero replicas is not an error.
WithReplicas()with an empty list routes reads to the primary, which makes it safe to configure the resolver unconditionally and add replicas later. - An out-of-range
UseReplica(i)silently falls back to a load-balanced replica rather than failing. Treat the index as a hint, not a guarantee.
4. Putting it together
Cache, pool and replicas are meant to be configured once at startup:
func setup() (*zorm.StmtCache, error) {
primary, err := zorm.ConnectPostgres(primaryDSN, &zorm.DBConfig{
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: time.Hour,
})
if err != nil {
return nil, err
}
// Replicas usually want a larger pool — they take the read traffic
replica, err := zorm.ConnectPostgres(replicaDSN, &zorm.DBConfig{
MaxOpenConns: 50,
MaxIdleConns: 10,
ConnMaxLifetime: time.Hour,
})
if err != nil {
return nil, err
}
zorm.SetGlobalDB(primary) // fallback for code paths that bypass the resolver
zorm.ConfigureDBResolver(
zorm.WithPrimary(primary),
zorm.WithReplicas(replica),
zorm.WithLoadBalancer(zorm.RoundRobinLB),
)
// One cache for both handles — entries are keyed per handle
return zorm.NewStmtCache(200), nil
}
// In a request handler
users, err := zorm.New[User]().
WithStmtCache(cache).
Where("active", true).
Limit(50).
Get(ctx) // replica + cached statement
5. Allocation and memory control
Model pooling. Acquire[T]() takes a Model[T] from a sync.Pool instead
of allocating one; Release() returns it. Worth using on hot paths that build a
query per request:
m := zorm.Acquire[User]()
defer m.Release()
users, err := m.Where("active", true).Limit(20).Get(ctx)
// `users` stays valid after Release — the entities are separate allocations
Acquire binds the current GlobalDB at acquire time. Never touch the model
after Release(): its state is cleared and it may already be in use elsewhere.
Dirty-tracking memory. Loading an entity stores a baseline so Save can
compute dirty columns. Baselines live in a bounded LRU — 50,000 entities by
default. A long-running service that streams through many distinct rows should
bound it explicitly:
zorm.ConfigureDirtyTracking(10000) // 0 means unbounded — not recommended for long-lived processes
For a batch job, scope the tracking instead so it is released deterministically:
scope := zorm.NewTrackingScope()
defer scope.Close() // drops the baselines for everything loaded through this model
model := zorm.New[User]().WithTrackingScope(scope)
users, _ := model.Get(ctx)
zorm.TrackedEntityCount() reports the current count (useful as a gauge metric);
zorm.ClearAllOriginals() drops everything at once.
Large result sets. Don't materialize what you don't need: use
Cursor to stream row by row, or
Chunk to process in fixed-size batches.
What to reach for
| Symptom | Knob |
|---|---|
| Connection exhaustion / "too many clients" | DBConfig.MaxOpenConns, ConnMaxLifetime |
| High CPU parsing the same SQL repeatedly | WithStmtCache |
| Read traffic saturating the primary | ConfigureDBResolver + replicas |
| Stale reads right after a write | UsePrimary() on that read |
| Allocation churn in a hot handler | Acquire[T]() / Release() |
| Memory growth in a long-running worker | ConfigureDirtyTracking, TrackingScope |
OOM on a large Get |
Cursor or Chunk |
API Reference
Query Methods
| Method | Description | Returns |
|---|---|---|
Get(ctx) |
Execute query and return all results | []*T, error |
First(ctx) |
Execute query and return first result | *T, error |
Find(ctx, id) |
Find record by primary key | *T, error |
FindOrFail(ctx, id) |
Find record or return error | *T, error |
Exists(ctx) |
Check if any record matches | bool, error |
Count(ctx) |
Count matching records | int64, error |
Sum(ctx, column) |
Sum of column values | float64, error |
Avg(ctx, column) |
Average of column values | float64, error |
Pluck(ctx, column) |
Get single column values | []any, error |
CountOver(ctx, column) |
Count rows per partition of a column | map[string]int64, error |
Write Methods
| Method | Description |
|---|---|
Create(ctx, entity) |
Insert single record |
CreateMany(ctx, entities) |
Insert multiple records in one statement |
BulkInsert(ctx, entities) |
Insert multiple records, running the create hooks |
Update(ctx, entity) |
Update all non-PK columns by primary key |
Save(ctx, entity) |
Update only dirty columns; optimistic-lock aware |
UpdateMany(ctx, values) |
Update multiple records matching query |
UpdateManyByKey(ctx, lookup, target, map) |
Update records by matching lookup column keys |
Delete(ctx) |
Delete records matching query |
DeleteMany(ctx) |
Alias for Delete |
FirstOrCreate(ctx, attrs, values) |
Find first or create new |
UpdateOrCreate(ctx, attrs, values) |
Update existing or create new |
Query Builder Methods
| Method | Description |
|---|---|
Select(columns...) |
Specify columns to select |
Distinct() |
Add DISTINCT to query |
DistinctBy(columns...) |
PostgreSQL DISTINCT ON |
Where(query, args...) |
Add WHERE condition |
OrWhere(query, args...) |
Add OR WHERE condition |
WhereIn(column, values) |
WHERE column IN (...) |
OrWhereIn(column, values) |
OR WHERE column IN (...) |
WhereNotIn(column, values) |
WHERE column NOT IN (...) |
OrWhereNotIn(column, values) |
OR WHERE column NOT IN (...) |
WhereNull(column) |
WHERE column IS NULL |
WhereNotNull(column) |
WHERE column IS NOT NULL |
OrWhereNull(column) |
OR column IS NULL |
OrWhereNotNull(column) |
OR column IS NOT NULL |
WhereHas(relation, callback) |
WHERE EXISTS subquery |
OrderBy(column, direction) |
Add ORDER BY |
Latest(column?) |
ORDER BY column DESC |
Oldest(column?) |
ORDER BY column ASC |
GroupBy(columns...) |
Add GROUP BY |
Having(query, args...) |
Add HAVING |
Limit(n) |
Set LIMIT |
Offset(n) |
Set OFFSET |
Lock(mode) |
Add FOR UPDATE/SHARE |
Utility Methods
| Method | Description |
|---|---|
Clone() |
Deep copy the query builder |
Table(name) |
Override table name |
TableName() |
Get current table name |
SetDB(db) |
Set custom DB connection |
WithTx(tx) |
Use transaction |
WithContext(ctx) |
Set context |
WithStmtCache(cache) |
Enable statement caching |
Scope(fn) |
Apply reusable query logic |
Print() |
Get SQL without executing |
Raw(sql, args...) |
Set raw SQL query |
Exec(ctx) |
Execute raw query |
Query Builder Details
Where Conditions
// Equality
zorm.New[User]().Where("name", "John").Get(ctx)
// Operators
zorm.New[User]().Where("age", ">", 18).Get(ctx)
zorm.New[User]().Where("email", "LIKE", "%@example.com").Get(ctx)
zorm.New[User]().Where("status", "!=", "inactive").Get(ctx)
// Map (multiple AND conditions)
zorm.New[User]().Where(map[string]any{
"name": "John",
"age": 25,
}).Get(ctx)
// Struct (non-zero fields)
zorm.New[User]().Where(&User{Name: "John", Age: 25}).Get(ctx)
// Nested/Grouped conditions — single-predicate grouping wraps the
// callback's predicate in parentheses
zorm.New[User]().Where(func(q *zorm.Model[User]) {
q.Where("age", ">", 18)
}).Where("active", true).Get(ctx)
// WHERE (age > $1) AND active = $2
// NULL checks
zorm.New[User]().WhereNull("deleted_at").Get(ctx)
zorm.New[User]().WhereNotNull("verified_at").Get(ctx)
// IN clause
zorm.New[User]().WhereIn("id", []any{1, 2, 3}).Get(ctx)
zorm.New[User]().WhereNotIn("status", []any{"banned", "archived"}).Get(ctx)
// OR conditions
zorm.New[User]().Where("age", ">", 18).OrWhere("verified", true).Get(ctx)
zorm.New[User]().OrWhereNotIn("status", []any{"banned", "archived"}).Get(ctx)
zorm.New[User]().OrWhereIn("id", []any{1, 2, 3}).Get(ctx)
// Raw fragment with bound arguments — any fragment containing ? is treated as
// raw SQL, with the arguments bound in order
zorm.New[User]().
Where("id IN (SELECT user_id FROM memberships WHERE role = ?)", "admin").
Get(ctx)
Security note on the raw form. It is an escape hatch. The fragment is checked
for SQL comments and statement separators, but that cannot stop logical
injection ("active = ? OR 1=1"). Keep the fragment a trusted constant and pass
user input as bound arguments — never concatenate it into the string.
Input validation
Builder methods validate what you give them. Invalid input — a column name,
operator, ORDER BY direction, lock mode, CTE name or HAVING expression that
fails validation — records a build error instead of quietly changing the query.
The error is returned by the terminal call:
users, err := zorm.New[User]().
Where("name; DROP TABLE users", "John"). // rejected here
Get(ctx) // reported here
if errors.Is(err, zorm.ErrInvalidColumnName) {
// the query never ran
}
Rules worth knowing:
- Write methods refuse to run with a build error. This matters most for
UpdateMany/UpdateManyByKey: a droppedWHEREwould otherwise turn a targeted update into a table-wide one. - Errors propagate out of nested scopes. A rejected condition inside a
Where(func)group or aWithCallbackcallback fails the outer query rather than silently dropping the group or the relation filter. OrderByrejects an unrecognized direction. Earlier versions coerced anything that was notASC/DESCtoDESC, which silently ordered results the opposite way from the request."asc","ASC","desc"and"DESC"are unaffected.
Exists Check
// Check if any matching record exists (efficient - uses SELECT 1 LIMIT 1)
exists, err := zorm.New[User]().Where("email", "[email protected]").Exists(ctx)
if exists {
fmt.Println("User exists!")
}
Pluck (Single Column)
// Get just the email column from all users
emails, err := zorm.New[User]().Where("active", true).Pluck(ctx, "email")
for _, email := range emails {
fmt.Println(email)
}
CountOver (Counts per Partition)
CountOver counts rows per distinct value of a column using
COUNT(*) OVER (PARTITION BY ...):
// How many orders does each customer have, among orders over $100?
counts, err := zorm.New[Order]().
Where("amount", ">", 100).
CountOver(ctx, "customer_id")
for customerID, n := range counts {
fmt.Printf("customer %s has %d orders\n", customerID, n)
}
Keys are normalized to strings regardless of the type the driver scanned the
column into — SQLite hands back int64 for integers, some drivers hand back
[]byte for binary and UUID columns, and a []byte cannot be a map key at all.
The return type is map[string]int64 for that reason; it was map[any]int64 in
earlier versions, and making the key type explicit turns an out-of-date lookup
(counts[int64(5)]) into a compile error rather than a silent zero.
CountOver honors JOINs, CTEs, WHERE and the statement cache like the other
aggregates.
Scalar Queries (Type-Safe Single Column)
ScalarQuery[T] provides a type-safe query builder for fetching single-column scalar values. Unlike Model[T] which returns full struct records, ScalarQuery returns simple typed values like []string, []int64, []float64, etc.
// Example 1: Get all usernames from users table
names, err := zorm.Query[string]().
Table("users").
Select("name").
Where("active", true).
Get(ctx)
// names is []string{"Alice", "Bob", "Charlie"}
// Example 2: Get user IDs ordered by creation date
ids, err := zorm.Query[int64]().
Table("users").
Select("id").
OrderBy("created_at", "DESC").
Limit(100).
Get(ctx)
// ids is []int64{42, 41, 40, ...}
// Example 3: Get distinct roles with count filtering
roles, err := zorm.Query[string]().
Table("users").
Select("role").
Distinct().
GroupBy("role").
Having("COUNT(*) >", 5).
Get(ctx)
// roles is []string{"admin", "editor"} (roles with more than 5 users)
ScalarQuery supports the same query builder methods as Model:
Where,OrWhere,WhereIn,WhereNotIn,WhereNull,WhereNotNullOrderBy,Limit,OffsetDistinct,GroupBy,HavingFirst(returns single value),Count(returns row count)SetDB,WithTx,Clone,Print
Cursor (Memory-Efficient Iteration)
For large datasets, use Cursor to iterate row by row without loading everything into memory:
cursor, err := zorm.New[User]().Where("active", true).Cursor(ctx)
if err != nil {
return err
}
defer cursor.Close()
for cursor.Next() {
user, err := cursor.Scan(ctx)
if err != nil {
return err
}
// Process user one at a time
fmt.Println(user.Name)
}
if err := cursor.Err(); err != nil {
return err
}
Check cursor.Err() after iteration: cancellation, timeouts, and driver errors
also make Next() return false. Normal exhaustion returns a nil error.
FirstOrCreate & UpdateOrCreate
// Find first matching record, or create if not found
user, err := zorm.New[User]().FirstOrCreate(ctx,
map[string]any{"email": "[email protected]"}, // Search attributes
map[string]any{"name": "John", "age": 25}, // Values for creation
)
// Find and update, or create if not found
user, err := zorm.New[User]().UpdateOrCreate(ctx,
map[string]any{"email": "[email protected]"}, // Search attributes
map[string]any{"name": "John Updated"}, // Values to set
)
Find-then-create is not atomic: a concurrent caller can insert the same row
between the two statements. When the INSERT loses that race with a duplicate-key
error, the lookup is retried once and the winning row is returned (or, for
UpdateOrCreate, updated). A duplicate-key conflict on some other unique
constraint — one the search attributes do not select for — is returned to you
unchanged, since retrying could not resolve it.
Pagination
// Full pagination (with total count - 2 queries)
result, err := zorm.New[User]().Paginate(ctx, 1, 15)
fmt.Println(result.Data) // []*User
fmt.Println(result.Total) // Total record count
fmt.Println(result.CurrentPage) // 1
fmt.Println(result.LastPage) // Calculated last page
fmt.Println(result.PerPage) // 15
// Simple pagination (no count - 1 query, faster)
result, err := zorm.New[User]().SimplePaginate(ctx, 1, 15)
// result.Total will be -1 (skipped)
Clone (Reuse Queries Safely)
baseQuery := zorm.New[User]().Where("active", true)
// Clone prevents modifying original
admins, _ := baseQuery.Clone().Where("role", "admin").Get(ctx)
users, _ := baseQuery.Clone().Limit(10).Get(ctx)
// Original is unchanged
all, _ := baseQuery.Get(ctx)
Custom Table Name
// Override table name for this query
users, _ := zorm.New[User]().Table("archived_users").Get(ctx)
Lifecycle Hooks
ZORM supports lifecycle hooks that are automatically called during CRUD operations.
Available Hooks
| Hook | When Called | *Tx Variant |
|---|---|---|
BeforeCreate(ctx) |
Before INSERT | BeforeCreateTx(ctx, tx *Tx) |
AfterCreate(ctx) |
After INSERT | AfterCreateTx(ctx, tx *Tx) |
BeforeUpdate(ctx) |
Before UPDATE | BeforeUpdateTx(ctx, tx *Tx) |
AfterUpdate(ctx) |
After UPDATE | AfterUpdateTx(ctx, tx *Tx) |
BeforeDelete(ctx) |
Before DELETE | BeforeDeleteTx(ctx, tx *Tx) |
AfterDelete(ctx) |
After DELETE | AfterDeleteTx(ctx, tx *Tx) |
AfterFind(ctx) |
After SELECT (per row) | — |
If both a plain hook and its *Tx variant are defined on a model, only the *Tx variant fires — they never both run.
Implementing Hooks
type User struct {
ID int64
Name string
Email string
CreatedAt time.Time
UpdatedAt time.Time
}
// BeforeCreate is called before inserting a new record
func (u *User) BeforeCreate(ctx context.Context) error {
// Validate
if u.Email == "" {
return errors.New("email is required")
}
// Set defaults
u.CreatedAt = time.Now()
// Normalize data
u.Email = strings.ToLower(u.Email)
return nil
}
// BeforeUpdate is called before updating a record
func (u *User) BeforeUpdate(ctx context.Context) error {
// Validate
if u.Name == "" {
return errors.New("name cannot be empty")
}
// updated_at is set automatically by ZORM
return nil
}
// AfterUpdate is called after a successful update
func (u *User) AfterUpdate(ctx context.Context) error {
// Log, send notifications, update cache, etc.
log.Printf("User %d updated", u.ID)
return nil
}
Hook Execution Flow
// Create flow:
// 1. created_at auto-set if column exists and field is zero
// 2. BeforeCreate(ctx) called
// 3. INSERT executed
// 4. ID populated
user := &User{Name: "John", Email: "[email protected]"}
err := zorm.New[User]().Create(ctx, user)
// BeforeCreate lowercases email to "[email protected]"
// Update flow:
// 1. updated_at set automatically
// 2. BeforeUpdate(ctx) called
// 3. UPDATE executed
// 4. AfterUpdate(ctx) called
user.Name = "Jane"
err = zorm.New[User]().Update(ctx, user)
Transactional Hooks (*Tx variants)
Plain hooks receive only context.Context, so any DB work they do runs on a separate connection from the parent INSERT/UPDATE/DELETE. If the parent SQL fails, the hook's writes are not rolled back.
To do hook-side DB work atomically with the parent operation, implement the *Tx variant instead. It receives the active *zorm.Tx for the running operation:
type User struct {
ID int64
Email string
Name string
}
// AfterCreateTx writes an audit row through the SAME transaction as the INSERT.
// If anything later fails and the transaction rolls back, the audit row
// disappears with the user row.
func (u *User) AfterCreateTx(ctx context.Context, tx *zorm.Tx) error {
_, err := tx.Tx.ExecContext(ctx,
`INSERT INTO audit_log (entity, entity_id, action) VALUES (?, ?, ?)`,
"user", u.ID, "created",
)
return err
}
Auto-opened transactions
If a model implements any *Tx variant relevant to the operation, and the call is made outside an existing transaction, ZORM auto-opens one for that call:
// No outer transaction — ZORM opens one because User has AfterCreateTx.
// The INSERT and the hook's audit-log write commit together, or roll back together.
err := zorm.New[User]().Create(ctx, &User{Email: "[email protected]", Name: "Ada"})
Inside an existing transaction (Transaction(...) + WithTx(tx)), no extra transaction is opened — the hook receives the outer *Tx so several operations can share one atomic boundary:
err := zorm.Transaction(ctx, func(tx *zorm.Tx) error {
if err := zorm.New[User]().WithTx(tx).Create(ctx, user); err != nil {
return err
}
return zorm.New[Order]().WithTx(tx).Create(ctx, order)
})
// AfterCreateTx on both User and Order see the same *Tx.
// If either fails, both inserts and both hook side-effects roll back together.
Calling ZORM from inside a *Tx hook
A *Tx hook can call ZORM itself bound to the same transaction. This is the idiomatic way to insert a related row atomically:
func (u *User) AfterCreateTx(ctx context.Context, tx *zorm.Tx) error {
profile := &Profile{UserID: u.ID, DisplayName: u.Name}
return zorm.New[Profile]().WithTx(tx).Create(ctx, profile)
}
Note: in-memory mutations to Go fields are never rolled back regardless of variant. Only DB writes performed through the passed *Tx are atomic with the parent SQL.
Accessors (Computed Attributes)
Define getter methods to compute virtual attributes. Methods starting with Get are automatically called after scanning. The struct must have an Attributes map[string]any field to store computed values.
type User struct {
ID int64
FirstName string
LastName string
Attributes map[string]any // Holds computed values
}
// Accessor: GetFullName -> attributes["full_name"]
func (u *User) GetFullName() string {
return u.FirstName + " " + u.LastName
}
// Accessor: GetInitials -> attributes["initials"]
func (u *User) GetInitials() string {
return string(u.FirstName[0]) + string(u.LastName[0])
}
// Usage
user, _ := zorm.New[User]().Find(ctx, 1)
fmt.Println(user.Attributes["full_name"]) // "John Doe"
fmt.Println(user.Attributes["initials"]) // "JD"
Relationships
Defining Relations
Relations are defined as methods on your model that return a relation type. The method name can be either RelationName or RelationNameRelation (e.g., Posts or PostsRelation).
type User struct {
ID int64
Name string
Posts []*Post // HasMany
Profile *Profile // HasOne
}
// HasMany: User has many Posts
// Method can be named "Posts" or "PostsRelation"
func (u User) PostsRelation() zorm.HasMany[Post] {
return zorm.HasMany[Post]{
ForeignKey: "user_id", // Column in posts table
LocalKey: "id", // Optional, defaults to primary key
}
}
// HasOne: User has one Profile
func (u User) ProfileRelation() zorm.HasOne[Profile] {
return zorm.HasOne[Profile]{
ForeignKey: "user_id",
}
}
type Post struct {
ID int64
UserID int64
Title string
Author *User // BelongsTo
}
// BelongsTo: Post belongs to User
func (p Post) AuthorRelation() zorm.BelongsTo[User] {
return zorm.BelongsTo[User]{
ForeignKey: "user_id", // Column in posts table
OwnerKey: "id", // Optional, defaults to primary key
}
}
Custom Table Names in Relations
func (u User) PostsRelation() zorm.HasMany[Post] {
return zorm.HasMany[Post]{
ForeignKey: "user_id",
Table: "blog_posts", // Use custom table name
}
}
Eager Loading
// Load single relation (use the relation name without "Relation" suffix)
users, _ := zorm.New[User]().With("Posts").Get(ctx)
// Load multiple relations
users, _ := zorm.New[User]().With("Posts", "Profile").Get(ctx)
// Load nested relations
users, _ := zorm.New[User]().With("Posts.Comments").Get(ctx)
// Load with constraints
// Limit applies per parent: each user gets up to 5 published posts, not 5
// posts shared across all users. OrderBy decides which 5 each user keeps.
users, _ := zorm.New[User]().WithCallback("Posts", func(q *zorm.Model[Post]) {
q.Where("published", true).
OrderBy("created_at", "DESC").
Limit(5)
}).Get(ctx)
A validation failure inside the callback is returned by Get / Load; the
relation is not loaded unfiltered.
Lazy Loading
user, _ := zorm.New[User]().Find(ctx, 1)
// Load relation on existing entity
err := zorm.New[User]().Load(ctx, user, "Posts")
// Load on slice
users, _ := zorm.New[User]().Get(ctx)
err := zorm.New[User]().LoadSlice(ctx, users, "Posts", "Profile")
Many-to-Many Relations
type User struct {
ID int64
Roles []*Role
}
func (u User) RolesRelation() zorm.BelongsToMany[Role] {
return zorm.BelongsToMany[Role]{
PivotTable: "role_user", // Join table
ForeignKey: "user_id", // FK in pivot table
RelatedKey: "role_id", // Related FK in pivot table
}
}
Managing Many-to-Many Associations
ZORM provides three methods to manage pivot table associations: Attach, Detach, and Sync.
user := &User{ID: 1}
// Attach - Add new associations (inserts into pivot table)
err := zorm.New[User]().Attach(ctx, user, "Roles", []any{3, 4}, nil)
// Adds role_user entries: (1,3), (1,4)
// Attach with pivot data (extra columns in pivot table)
pivotData := map[any]map[string]any{
3: {"assigned_at": time.Now(), "assigned_by": 1},
4: {"assigned_at": time.Now(), "assigned_by": 1},
}
err = zorm.New[User]().Attach(ctx, user, "Roles", []any{3, 4}, pivotData)
// Detach - Remove specific associations
err = zorm.New[User]().Detach(ctx, user, "Roles", []any{2})
// Removes role_user entry: (1,2)
// Detach all - Remove all associations for the relation
err = zorm.New[User]().Detach(ctx, user, "Roles", nil)
// Removes all role_user entries where user_id = 1
Sync - Synchronize Associations
Sync is a handy method for managing many-to-many relations. It synchronizes the pivot table to match exactly the IDs you provide:
- Attaches IDs that are in the new list but not in the database
- Detaches IDs that are in the database but not in the new list
- Keeps IDs that exist in both (no duplicate entry errors)
The read, the DELETE and the INSERT run in a single transaction, so a failing
attach cannot leave the association half-synced. When the model is already bound
to a transaction (WithTx), that one is used instead of opening a nested one.
user := &User{ID: 1}
// Current roles in DB: [1, 2, 3]
// Sync to new set of roles
err := zorm.New[User]().Sync(ctx, user, "Roles", []any{1, 2, 4}, nil)
// Result:
// - Role 1: kept (exists in both)
// - Role 2: kept (exists in both)
// - Role 3: detached (was in DB, not in new list)
// - Role 4: attached (not in DB, is in new list)
// Final roles in DB: [1, 2, 4]
// Sync with pivot data for new attachments
pivotData := map[any]map[string]any{
4: {"assigned_at": time.Now()},
}
err = zorm.New[User]().Sync(ctx, user, "Roles", []any{1, 2, 4}, pivotData)
Common Sync Use Cases:
// Replace all user roles with a new set
err := zorm.New[User]().Sync(ctx, user, "Roles", []any{1, 2}, nil)
// Remove all roles (sync with empty list)
err = zorm.New[User]().Sync(ctx, user, "Roles", []any{}, nil)
// Form submission: update user roles from checkbox selection
selectedRoleIDs := []any{1, 3, 5} // From form
err = zorm.New[User]().Sync(ctx, user, "Roles", selectedRoleIDs, nil)
Polymorphic Relations
type Image struct {
ID int64
URL string
ImageableType string // "users" or "posts"
ImageableID int64
}
// The value stored in the type column is the parent's morph type: its
// MorphType() method when declared, otherwise its Go struct name ("User").
func (u User) MorphType() string { return "users" }
func (p Post) MorphType() string { return "posts" }
// MorphOne: User has one Image
func (u User) AvatarRelation() zorm.MorphOne[Image] {
return zorm.MorphOne[Image]{
Type: "imageable_type", // Type column
ID: "imageable_id", // ID column
}
}
// MorphMany: Post has many Images
func (p Post) ImagesRelation() zorm.MorphMany[Image] {
return zorm.MorphMany[Image]{
Type: "imageable_type",
ID: "imageable_id",
}
}
// MorphTo: the inverse. Type/ID are column names here too, and the TypeMap
// keys are the same morph type values written into the type column.
func (i Image) ImageableRelation() zorm.MorphTo[any] {
return zorm.MorphTo[any]{
Type: "imageable_type",
ID: "imageable_id",
TypeMap: map[string]any{
"users": User{},
"posts": Post{},
},
}
}
// Loading with type constraints
images, _ := zorm.New[Image]().WithMorph("Imageable", map[string][]string{
"users": {"Profile"}, // When type=users, also load Profile
"posts": {}, // When type=posts, just load Post
}).Get(ctx)
Transactions
// Function-based transaction
err := zorm.Transaction(ctx, func(tx *zorm.Tx) error {
user := &User{Name: "John"}
if err := zorm.New[User]().WithTx(tx).Create(ctx, user); err != nil {
return err // Rollback
}
post := &Post{UserID: user.ID, Title: "First Post"}
if err := zorm.New[Post]().WithTx(tx).Create(ctx, post); err != nil {
return err // Rollback
}
return nil // Commit
})
// Model-based transaction
err = zorm.New[User]().Transaction(ctx, func(tx *zorm.Tx) error {
return zorm.New[User]().WithTx(tx).Create(ctx, &User{Name: "Jane"})
})
Transaction features:
- Auto-rollback on error return
- Auto-rollback on panic (re-panics after rollback)
- Auto-commit on nil return
- Routed to the primary when a
DBResolveris configured - If the rollback itself fails, the returned error matches both your original
error and
errors.Is(err, zorm.ErrRollbackFailed)— worth branching on, since the connection state is then unknown
Error Handling
ZORM provides comprehensive error handling with categorized errors.
Sentinel Errors
import "github.com/rezakhademix/zorm"
// Query errors
zorm.ErrRecordNotFound // No matching record
zorm.ErrRequiresRawQuery // Exec() called without Raw()
// Model errors
zorm.ErrInvalidModel // Invalid model type
zorm.ErrNilPointer // Nil pointer passed
zorm.ErrNoContext // No context provided
// Relation errors
zorm.ErrRelationNotFound // Relation method not found
zorm.ErrInvalidRelation // Invalid relation type
zorm.ErrInvalidConfig // Invalid relation config (e.g. missing pivot keys)
// Constraint violations
zorm.ErrDuplicateKey // Unique constraint violation
zorm.ErrForeignKey // Foreign key constraint violation
zorm.ErrNotNullViolation // NOT NULL constraint violation
zorm.ErrCheckViolation // CHECK constraint violation
// Connection errors
zorm.ErrConnectionFailed // Connection refused
zorm.ErrConnectionLost // Connection lost during operation
zorm.ErrTimeout // Operation timeout
zorm.ErrNilDatabase // No DB configured (GlobalDB, SetDB or resolver)
// Transaction errors
zorm.ErrTransactionDeadlock // Deadlock detected
zorm.ErrSerializationFailure // Serialization failure
zorm.ErrOptimisticLock // Save() detected concurrent modification
zorm.ErrSaveUntracked // Save() called on entity not loaded from DB
zorm.ErrVersionOverflow // version column is at its type's maximum
zorm.ErrRollbackFailed // rollback failed after a failed transaction
// Schema errors
zorm.ErrColumnNotFound // Column doesn't exist
zorm.ErrTableNotFound // Table doesn't exist
zorm.ErrInvalidSyntax // SQL syntax error
zorm.ErrInvalidColumnName // Builder input rejected by validation
Error Helper Functions
user, err := zorm.New[User]().Find(ctx, 999)
// Check specific error types
if zorm.IsNotFound(err) {
// Handle not found
}
if zorm.IsDuplicateKey(err) {
// Handle duplicate
}
if zorm.IsConstraintViolation(err) {
// Any constraint violation
}
if zorm.IsConnectionError(err) {
// Connection failed or lost
}
if zorm.IsTimeout(err) {
// Operation timed out
}
if zorm.IsDeadlock(err) {
// Transaction deadlock - retry
}
if zorm.IsSchemaError(err) {
// Missing column, table, or syntax error
}
QueryError Details
user, err := zorm.New[User]().Create(ctx, &User{Email: "[email protected]"})
if err != nil {
if qe := zorm.GetQueryError(err); qe != nil {
fmt.Println(qe.Query) // The SQL that failed
fmt.Println(qe.Args) // Query arguments
fmt.Println(qe.Operation) // "INSERT", "SELECT", etc.
fmt.Println(qe.Table) // Table name (if detected)
fmt.Println(qe.Constraint) // Constraint name (if detected)
}
}
Advanced Features
Save() vs Update()
Update(ctx, entity) writes every non-primary column. Save(ctx, entity)
inspects the dirty-tracking baseline (set when the entity was loaded via
Find / First / Get) and emits an UPDATE containing only columns that
actually changed. If nothing changed, Save is a no-op and issues no SQL.
user, _ := zorm.New[User]().Find(ctx, 1)
user.Name = "Renamed"
err := zorm.New[User]().Save(ctx, user)
// UPDATE users SET name = ?, updated_at = ? WHERE id = ?
// (only the dirty columns; "value", "email", etc. are NOT rewritten)
Save requires:
- a non-zero primary key on the entity (use
Createto insert); and - a dirty-tracking baseline, i.e. the entity must have been loaded via
Find/First/Get. A manually-constructed entity is rejected withErrSaveUntrackedso it cannot silently rewrite columns to zero values. UseUpdatefor full-column writes from a hand-built entity.
Hooks: Save fires BeforeUpdate / AfterUpdate (and the *Tx variants)
in the same positions as Update — including when the entity is clean and no
SQL is issued, so audit-logging hooks observe every Save call. (BeforeUpdate
runs before the dirty set is computed, so a hook that mutates fields turns a
clean Save into a real UPDATE.) If the row was deleted between load and save
and no version column is configured, Save returns ErrRecordNotFound.
Optimistic Concurrency
Add a version tag to a numeric field to enable optimistic locking on
Save(). The version is checked in WHERE and incremented in SET; if a
concurrent writer already bumped it, the UPDATE matches zero rows and Save
returns ErrOptimisticLock.
type Order struct {
ID int64 `zorm:"primaryKey"`
Status string
Version int64 `zorm:"version"` // marks the optimistic-lock column
}
a, _ := zorm.New[Order]().Find(ctx, 1) // Version == 1
b, _ := zorm.New[Order]().Find(ctx, 1) // Version == 1
a.Status = "paid"
_ = zorm.New[Order]().Save(ctx, a) // OK; a.Version == 2 in DB and memory
b.Status = "cancelled"
err := zorm.New[Order]().Save(ctx, b)
if zorm.IsOptimisticLock(err) {
// re-load, reapply change, retry
}
Supported version-field kinds: int, int32, int64, uint, uint32,
uint64. At most one version field per model. The check applies only to
Save; Update and UpdateColumns are left as full-write escape hatches.
Statement Caching
See Advanced Start → Add a statement cache.
Read/Write Splitting
See Advanced Start → Add replicas.
Common Table Expressions (CTEs)
// String CTE
users, _ := zorm.New[User]().
WithCTE("active_users", "SELECT * FROM users WHERE active = true").
Raw("SELECT * FROM active_users WHERE age > 18").
Get(ctx)
// Subquery CTE
subQuery := zorm.New[User]().Where("active", true)
users, _ := zorm.New[User]().
WithCTE("active_users", subQuery).
Raw("SELECT * FROM active_users").
Get(ctx)
Full-Text Search (PostgreSQL)
// Basic full-text search
articles, _ := zorm.New[Article]().
WhereFullText("content", "database sql").Get(ctx)
// With language config
articles, _ := zorm.New[Article]().
WhereFullTextWithConfig("content", "base de datos", "spanish").Get(ctx)
// Pre-computed tsvector column (fastest)
articles, _ := zorm.New[Article]().
WhereTsVector("search_vector", "golang & performance").Get(ctx)
// Phrase search (word order matters)
articles, _ := zorm.New[Article]().
WherePhraseSearch("title", "getting started").Get(ctx)
Row Locking
// Lock for update (exclusive)
user, _ := zorm.New[User]().Where("id", 1).Lock("UPDATE").First(ctx)
// Shared lock
user, _ := zorm.New[User]().Where("id", 1).Lock("SHARE").First(ctx)
// PostgreSQL-specific
user, _ := zorm.New[User]().Where("id", 1).Lock("NO KEY UPDATE").First(ctx)
Advanced Grouping
// ROLLUP
zorm.New[Order]().
Select("region", "city", "SUM(amount)").
GroupByRollup("region", "city").Get(ctx)
// CUBE
zorm.New[Order]().
Select("year", "month", "SUM(amount)").
GroupByCube("year", "month").Get(ctx)
// GROUPING SETS
zorm.New[Order]().
GroupByGroupingSets(
[]string{"region"},
[]string{"city"},
[]string{}, // Grand total
).Get(ctx)
Chunking Large Datasets
err := zorm.New[User]().Chunk(ctx, 1000, func(users []*User) error {
for _, user := range users {
// Process each user
}
return nil // Return error to stop chunking
})
Scopes (Reusable Query Logic)
func Active(q *zorm.Model[User]) *zorm.Model[User] {
return q.Where("active", true).WhereNull("deleted_at")
}
func Verified(q *zorm.Model[User]) *zorm.Model[User] {
return q.WhereNotNull("verified_at")
}
func RecentlyActive(q *zorm.Model[User]) *zorm.Model[User] {
return q.Where("last_login", ">", time.Now().AddDate(0, -1, 0))
}
// Chain scopes
users, _ := zorm.New[User]().
Scope(Active).
Scope(Verified).
Scope(RecentlyActive).
Get(ctx)
Parameterized Scopes
Scopes can also take arguments. Return a closure that matches the
func(*zorm.Model[T]) *zorm.Model[T] signature expected by Scope(...):
func Role(role string) func(*zorm.Model[User]) *zorm.Model[User] {
return func(q *zorm.Model[User]) *zorm.Model[User] {
return q.Where("role", role)
}
}
func RegisteredBetween(from, to time.Time) func(*zorm.Model[User]) *zorm.Model[User] {
return func(q *zorm.Model[User]) *zorm.Model[User] {
return q.
Where("created_at", ">=", from).
Where("created_at", "<=", to)
}
}
// Apply via Scope(...) just like parameter-less scopes
zorm.New[User]().
Scope(Role("admin")).
Scope(RegisteredBetween(from, to)).
Get(ctx)
Scopes are plain functions, not methods on Model[T] — they cannot be
chained directly (.Role("admin")); pass them to Scope(...).
Query Debugging
sql, args := zorm.New[User]().
Where("age", ">", 18).
OrderBy("name", "ASC").
Limit(10).
Print()
fmt.Println(sql) // SELECT * FROM users WHERE 1=1 AND age > $1 ORDER BY name ASC LIMIT 10
fmt.Println(args) // [18]
Placeholder rewriting is not dialect-dependent — ? becomes $N everywhere
(SQLite accepts $N too), and every execution path sends exactly what Print
reports, including Exec on a Raw query.
The generated SQL is also stable: clauses built from a map or a struct
(Where(map[string]any{...}), Where(&User{...}), UpdateMany(values)) are
emitted in a fixed order rather than Go's randomized map order. Print() output
is therefore safe to assert on in tests, and the statement cache sees one entry
per builder chain instead of one per iteration order.
Complete Example
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/rezakhademix/zorm"
)
type User struct {
ID int64
Name string
Email string
Age int
Active bool
CreatedAt time.Time
UpdatedAt time.Time
Posts []*Post
}
func (u *User) BeforeCreate(ctx context.Context) error {
u.CreatedAt = time.Now()
u.Active = true
return nil
}
func (u User) PostsRelation() zorm.HasMany[Post] {
return zorm.HasMany[Post]{ForeignKey: "user_id"}
}
type Post struct {
ID int64
UserID int64
Title string
Published bool
}
func main() {
ctx := context.Background()
// Connect
db, err := zorm.ConnectPostgres("postgres://...", nil)
if err != nil {
log.Fatal(err)
}
zorm.GlobalDB = db
// Create with hook
user := &User{Name: "John", Email: "[email protected]", Age: 25}
if err := zorm.New[User]().Create(ctx, user); err != nil {
log.Fatal(err)
}
fmt.Printf("Created user %d\n", user.ID)
// Query with relations
users, err := zorm.New[User]().
Where("age", ">", 18).
Where("active", true).
WithCallback("Posts", func(q *zorm.Model[Post]) {
q.Where("published", true).Limit(5)
}).
OrderBy("created_at", "DESC").
Limit(10).
Get(ctx)
if err != nil {
log.Fatal(err)
}
for _, u := range users {
fmt.Printf("%s has %d published posts\n", u.Name, len(u.Posts))
}
// FirstOrCreate
user, err = zorm.New[User]().FirstOrCreate(ctx,
map[string]any{"email": "[email protected]"},
map[string]any{"name": "Jane", "age": 30},
)
// Pagination
result, _ := zorm.New[User]().Paginate(ctx, 1, 15)
fmt.Printf("Page 1 of %d, Total: %d\n", result.LastPage, result.Total)
}
zorm benchmarks
Cross-ORM benchmark suite comparing zorm against:
All four run the same workload against the same in-memory SQLite database, with identical seed data, identical row shapes, and identical iteration semantics so the numbers compare apples to apples (with the caveats noted below).
The model
A portable two-table schema (users and posts) chosen to exercise the
common Go datatypes without needing a Postgres-only column type:
| Column | Go type | SQLite affinity |
|---|---|---|
| id | int64 |
INTEGER PK |
| name | string |
TEXT |
string |
TEXT UNIQUE | |
| age | int64 |
INTEGER |
| score | float64 |
REAL |
| is_active | bool |
INTEGER 0/1 |
| nickname | *string |
TEXT NULL |
| avatar | []byte |
BLOB |
| metadata | string |
TEXT (JSON-as-text) |
| created_at | time.Time |
DATETIME |
posts adds a user_id foreign key so User HasMany Post /
Post BelongsTo User is exercised by the eager-load benchmarks.
Benchmark
Recorded on Apple M3 Pro / darwin/arm64, SQLite :memory:, single connection. Raw go test -bench=. -benchmem output:
Side-by-side (ns/op · B/op · allocs/op)
| Operation | gorm (ns/op · B/op · allocs/op) | zorm (ns/op · B/op · allocs/op) | Summary |
|---|---|---|---|
| InsertOne | 11,147 · 6,732 · 87 | 11,637 · 4,661 · 72 | zorm 31% less memory, 21% fewer allocs |
| GetByPK | 8,320 · 5,516 · 109 | 9,120 · 4,818 · 103 | zorm 14.5% less memory, 6% fewer allocs |
| UpdateOne | 9,112 · 10,040 · 101 | 8,063 · 4,461 · 63 | zorm 12% faster, 56% less memory, 38% fewer allocs |
| DeleteOne | 6,161 · 3,106 · 40 | 5,876 · 1,879 · 29 | zorm 5% faster, 40% less memory, 28% fewer allocs |
| BulkInsert100 | 309,817 · 213,593 · 3,203 | 294,982 · 165,799 · 2,660 | zorm 5% faster, 22% less memory, 17% fewer allocs |
| BulkInsert1000 | 3,041,683 · 1,990,998 · 31,411 | 2,842,708 · 1,567,151 · 26,363 | zorm 7% faster, 21% less memory, 16% fewer allocs |
| FindWhereOrderLimit | 248,997 · 56,515 · 1,387 | 258,790 · 67,684 · 1,627 | gorm 4% faster, 16% less memory, 15% fewer allocs |
| TxInsert100 | 1,596,357 · 703,940 · 9,258 | 1,582,641 · 541,958 · 7,899 | zorm 1% faster, 23% less memory, 15% fewer allocs |
| EagerLoadHasMany | 1,466,133 · 627,644 · 17,223 | 1,147,633 · 442,953 · 11,536 | zorm 22% faster, 29% less memory, 33% fewer allocs |
| EagerLoadBelongsTo | 322,497 · 177,939 · 3,798 | 304,350 · 153,179 · 3,491 | zorm 6% faster, 14% less memory, 8% fewer allocs |
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
For suspected vulnerabilities, follow the security policy and avoid sharing sensitive details in public issues.
License
MIT License - see LICENSE file for details.