ORM
database/orm maps Go structs to tables on top of the query builder.
It works with *database.DB and *database.Tx.
Models
type User struct {
orm.Model // ID int64 (primary key), CreatedAt, UpdatedAt
Name string
Email *string // nullable column
Profile *Profile `orm:"hasOne"`
Posts []Post `orm:"hasMany"`
Roles []Role `orm:"belongsToMany"`
}
type Post struct {
orm.Model
orm.SoftDeletes // DeletedAt *time.Time
UserID int64
Title string
Author *User `orm:"belongsTo,foreignKey=user_id"`
}
| Rule | Default | Override |
|---|---|---|
| Table | plural snake_case type name (User → users, Category → categories) |
func (User) TableName() string |
| Column | snake_case field name (UserID → user_id) |
`db:"name"` |
| Skipped field | — | `db:"-"`; unexported fields are always skipped |
| Primary key | the id column |
`db:"code,pk"` |
Integer primary keys are generated by the database when zero. created_at and
updated_at fields (time.Time, *time.Time or sql.NullTime) are maintained
automatically. A nullable deleted_at field (*time.Time or sql.NullTime)
enables soft deletes. Embedded structs are flattened; embed them by value.
Use nullable Go types (*string, sql.NullString, *time.Time, …) for columns
that may be NULL. MySQL DSNs need parseTime=true to scan time columns.
orm.Register(User{}, Post{}) validates mappings and relationships at startup.
Models are otherwise parsed on first use and cached. orm.Describe(User{})
returns the table, primary key, columns and relations.
CRUD
user := User{Name: "Ada"}
err := orm.Create(ctx, db, &user) // sets user.ID, CreatedAt, UpdatedAt
found, err := orm.Find[User](ctx, db, user.ID)
if errors.Is(err, orm.ErrNotFound) { … } // also matches sql.ErrNoRows
found.Name = "Ada Lovelace"
err = orm.Save(ctx, db, found) // updates every column except id, created_at, deleted_at
err = orm.Delete(ctx, db, found) // soft delete if supported, otherwise DELETE
Save, Delete, ForceDelete and Restore return orm.ErrMissingPrimaryKey
for a model with a zero primary key.
Queries
users, err := orm.Query[User](db).
Where("active", "=", true).
OrderBy("name", "asc").
Limit(20).
Get(ctx) // []User
first, err := orm.Query[User](db).OrderBy("id", "asc").First(ctx)
n, err := orm.Query[User](db).WhereNull("email").Count(ctx)
page, meta, err := orm.Query[User](db).OrderBy("id", "asc").Paginate(ctx, 2, 25)
Where, WhereNull, WhereNotNull, WhereIn, WhereNotIn, OrderBy, Limit
and Offset behave as in the query builder. Builders are immutable.
Bulk writes return the number of affected rows:
n, err := orm.Query[User](db).WhereIn("id", ids).Update(ctx, map[string]any{"active": false})
n, err = orm.Query[Post](db).Where("user_id", "=", id).Delete(ctx)
Update also sets updated_at. Like the query builder, bulk Update, Delete,
ForceDelete and Restore refuse to run without a Where clause unless you call
AllRows().
Relationships
| Tag | Field type | Default keys | Options |
|---|---|---|---|
hasOne |
*Related |
related.<owner>_id = owner.id |
foreignKey, localKey |
hasMany |
[]Related |
related.<owner>_id = owner.id |
foreignKey, localKey |
belongsTo |
*Related |
owner.<field>_id = related.id |
foreignKey, ownerKey |
belongsToMany |
[]Related |
pivot <a>_<b> (alphabetical singulars, e.g. role_user) with <owner>_id and <related>_id |
pivot, foreignPivotKey, relatedPivotKey, localKey, relatedKey |
Options are written as orm:"hasMany,foreignKey=author_id". <owner> is the
snake_case type name, <field> the snake_case field name.
Load relations onto existing models:
err := orm.Load(ctx, db, &user, "Posts", "Profile")
err = orm.LoadAll(ctx, db, users, "Roles")
Eager loading
users, err := orm.Query[User](db).
With("Profile", "Roles", "Posts.Comments", "Posts.Author").
Get(ctx)
Each relation at each nesting level runs exactly one query
(WHERE key IN (…), chunked in batches of 500 keys), so loading 1 or 1,000 users
with their posts and comments always takes three queries. This avoids N+1
queries. Unmatched hasMany/belongsToMany fields are set to empty, non-nil
slices; unmatched hasOne/belongsTo fields are nil. Related rows are ordered by
primary key, and soft-deleted related rows are excluded.
Soft deletes
Models with a nullable deleted_at field (for example via orm.SoftDeletes) are
soft-deleted:
orm.Delete(ctx, db, &post) // UPDATE … SET deleted_at = now
orm.Restore(ctx, db, &post) // deleted_at = NULL
orm.ForceDelete(ctx, db, &post) // DELETE
orm.Query[Post](db).Count(ctx) // excludes deleted rows
orm.Query[Post](db).WithTrashed().Count(ctx) // includes them
orm.Query[Post](db).OnlyTrashed().Get(ctx) // only deleted rows
Create the column with table.SoftDeletes() in a migration.
Transactions
Pass a transaction anywhere a connection is accepted:
err := db.TransactionContext(ctx, func(tx *database.Tx) error {
return orm.Create(ctx, tx, &user)
})
Not yet supported
Model hooks, scopes, factories, seeders, upserts and table aliases in relations.