
ZORM is a modern ORM for Go that lets you write fluent, type-safe, SQL-like queries using Go generics — all without struct tags or heavy reflection.
It is built for developers who want:
- the ergonomics of an ORM,
- the clarity of handwritten SQL,
- and the safety guarantees of Go’s type system.
👉 GitHub: https://github.com/rezakhademix/zorm
The Problem with Typical Go ORMs
Many Go ORMs rely on:
- struct tags for mapping
- string-based queries with limited compile-time guarantees
This often leads to:
- duplicated schema definitions
- silent runtime errors due to typos
- harder refactoring when models change
ZORM takes a different path.
What Makes ZORM Different?
1. No Struct Tags
You define a plain Go struct:
type User struct {
ID int64
Name string
Email string
CreatedAt time.Time
}
ZORM automatically infers:
table name: users
columns: id, name, email, created_at
No db:"..." or json:"..." style tags are required for persistence.
This reduces boilerplate and keeps your models clean and idiomatic.
2. ZORM Type Safety with Generics
ZORM is built on Go generics, so queries are bound to a concrete type at compile time.
users, err := zorm.New[User]().Get(ctx)
3. Fluent, SQL-Like Query Builder
Queries are chainable and readable:
admins, err := zorm.New[User]().
Where("role =", "admin").
OrderBy("created_at DESC").
Limit(10).
Get(ctx)
You get composable queries without writing raw SQL everywhere.
You can also inspect the generated SQL:
q := zorm.New[User]().Where("age >", 18)
fmt.Println(q.Print())
Quick Start
- Install
go get github.com/rezakhademix/zorm
- Connect to the database
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
zorm.SetDB(db)
- ZORM Insert query
u := User{
Name: "Alice",
Email: "alice@example.com",
}
err = zorm.New[User]().Insert(ctx, &u)
Query records
users, err := zorm.New[User]().
Where("email LIKE", "%@example.com").
OrderBy("id DESC").
Get(ctx)
ZORM supports common relationships such as:
- HasOne
- HasMany
- BelongsTo
- Polymorphic relations
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
}
}
You can eager load related data without manual joins, keeping query logic centralized and reusable.
- Transactions
Transactions are explicit and composable:
err := zorm.Transaction(ctx, func(tx *zorm.Tx) error {
if err := tx.New[User]().Insert(ctx, &u); err != nil {
return err
}
if err := tx.New[Profile]().Insert(ctx, &p); err != nil {
return err
}
return nil
})
If any step fails, everything is rolled back.
Advanced Capabilities
ZORM is not limited to simple CRUD:
- prepared statement caching
- context-aware execution
- subqueries and nested queries
- support for complex SQL constructs when needed
- Sync - Synchronize Associations
// 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]
Main/Replica Splitting
zorm.ConfigureDBResolver(
zorm.WithPrimary(primaryDB),
zorm.WithReplicas(replica1, replica2),
zorm.WithLoadBalancer(zorm.RoundRobinLB),
)
// Automatic routing
users, _ := zorm.New[User]().Get(ctx) // Reads from replica
err := zorm.New[User]().Create(ctx, user) // Writes to primary
// Force primary for consistency
users, _ := zorm.New[User]().UsePrimary().Get(ctx)
// Force specific replica
users, _ := zorm.New[User]().UseReplica(0).Get(ctx)
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)
You can stay high-level for most operations and drop lower when necessary.
FAQ
1. Does ZORM use reflection?
ZORM minimizes runtime reflection and relies primarily on Go generics and compile-time type information. This reduces overhead and avoids many of the runtime surprises common in reflection-heavy ORMs.
2. Why are there no struct tags?
ZORM follows convention over configuration.
Field names are automatically mapped from CamelCase to snake_case, and table names are inferred from the struct name. This removes duplication between struct definitions and tag metadata and makes refactoring safer and simpler.
3. Is ZORM faster than other Go ORMs?
ZORM is designed to be lightweight and predictable by avoiding heavy reflection and unnecessary abstractions. Actual performance depends on your queries, database, and indexes, but the goal is to stay close to the cost of using database/sql directly while providing higher-level ergonomics.
4. Can I run raw SQL queries?
Yes.
ZORM focuses on query building and mapping, not restricting SQL. You can always fall back to raw SQL when needed and still scan results into your structs.
5. Does ZORM support transactions?
Yes.
Transactions are explicit and type-safe. You pass a function that receives a transaction handle, and ZORM commits or rolls back automatically based on the returned error.
6. Which databases are supported?
Any database that works with Go’s standard database/sql drivers (e.g. PostgreSQL, MySQL, SQLite, SQL Server).
ZORM is driver-agnostic and does not lock you into a specific backend.
7. Does ZORM handle migrations?
No.
ZORM is focused on querying and data access. Schema migrations should be handled by dedicated tools such as Goose, Golang-Migrate depending on your stack.
8. Can I define relationships between models?
Yes.
Common relations like one-to-one, one-to-many, and polymorphic associations are supported, with optional eager loading to reduce manual join boilerplate.
9. Is ZORM safe to use concurrently?
Yes.
Query builders are independent values. As long as your underlying *sql.DB is safe for concurrent use (which it is by design), ZORM operations can be used across goroutines.
10. When should I not use ZORM?
If you require:
- automatic schema generation and migrations
- database-first code generation
- deep integration with a specific database vendor feature set
In those cases, a migration framework or SQL code generator may be a better primary tool. ZORM is optimized for clean, type-safe query construction and data mapping in idiomatic Go.
Top comments (0)