Modeling
A Quark model is a plain Go struct. There's no base type to embed and no code
generation step: a field becomes a column when it has a db tag, and a field
becomes a relation when it has a rel tag. Everything on this page builds on one
example struct:
type Product struct {
ID int64 `db:"id" pk:"true"`
SKU string `db:"sku" quark:"unique,not_null" validate:"required"`
Name string `db:"name" quark:"not_null"`
Price float64 `db:"price" default:"0.00" quark:"not_null"`
Stock int `db:"stock" default:"0"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt *time.Time `db:"deleted_at"`
}
Table names
Quark pluralizes and snake-cases the struct name:
| Struct | Default table |
|---|---|
User | users |
Category | categories |
APIKey | api_keys |
Address | addresses |
When the table already exists under another name, implement TableName():
func (Product) TableName() string {
return "catalog_products"
}
Quark parses and caches a struct's metadata the first time it sees the type, so repeated queries don't re-reflect over the same shape.
The tags you'll use most
These five cover almost everything:
| Tag | Does |
|---|---|
db:"column" | Maps the field to a column. No db tag → the field is ignored entirely. |
pk:"true" | Marks the primary key (or a member of a composite key). |
quark:"not_null" | Emits NOT NULL in the schema. |
quark:"unique" | Emits UNIQUE. Combine them: quark:"unique,not_null". |
quark:"index" / quark:"index=name" | Declares a secondary index Migrate creates and PlanMigration proposes when missing. |
quark:"check=expr" / db:"col,enum=a|b" | Declares a CHECK on the column — an expression verbatim, or the allowed values of an enum — emitted as ck_<table>_<column>. |
validate:"rule" | Runs a validator/v10 rule before writes. |
That's enough to model most tables. The rest of this page covers the situations that need more: composite keys, soft deletes, optimistic locking, rich column types, and timezones.
Less common tags (sizing, defaults, renames, optimistic locking)
| Tag | Read by | Purpose |
|---|---|---|
db:"column,size=512" | Migrate, Sync | Sets VARCHAR/CHAR length (NVARCHAR/VARCHAR2 per dialect). |
db:"column,precision=18,scale=4" | Migrate, Sync, mappers | Turns a float32/float64 (or Nullable of one) into a fixed-point DECIMAL(p,s) — NUMBER(p,s) on Oracle — and reaches a TypeMapper through TypeOptions. On any other Go type the hint is ignored and the model carries a tag warning. |
db:"-" | parser | Ignores the field. |
default:"value" | Migrate | Emits DEFAULT value verbatim; bool defaults are normalized per dialect (below). |
nullable:"false" | Migrate | Also emits NOT NULL. |
quark:"version" | Update, Tracked.Save | Optimistic-locking column (see below). |
quark:"rename:old_col" | Sync | Renames old_col to the current column on the next sync. |
type Profile struct {
ID int64 `db:"id" pk:"true"`
Bio string `db:"bio,size=512"`
Price decimal.Decimal `db:"price,precision=18,scale=4"`
}
Unknown db options are ignored, so older tags keep working as new options are
added.
pkg/modelBoth read a tag called db, and they do not agree on what is inside it.
| Quark | Nucleus pkg/model | |
|---|---|---|
| what the tag holds | the column name, plus options | directives |
| separator | comma | semicolon |
| primary key | a separate pk:"true" tag | db:"pk" |
| column name | db:"email" | db:"column:email" |
| not null / unique | quark:"not_null,unique" | db:"not null;unique" |
A model written the other way used to produce a Quark column called literally
column:email;unique;not null — no error, no warning, and a table nobody
could query. Quark now refuses a db tag whose column name cannot be an
identifier, and the error says how to spell it here.
A tag whose column name happens to be a legal identifier but reads like a
directive — db:"pk", db:"readonly" — only warns: a table really can have a
column called pk, so refusing it would be wrong.
Primary keys
Tag the key with pk:"true":
type User struct {
ID int64 `db:"id" pk:"true"`
}
If no field carries pk:"true", Quark falls back to the field tagged db:"id".
An integer single-column key is treated as database-generated when it's zero on
insert (so you let the database assign it). String keys are caller-supplied:
type Session struct {
ID string `db:"id" pk:"true"`
UserID int64 `db:"user_id"`
}
Composite keys
Tag more than one field. Migrate emits a table-level
PRIMARY KEY (order_id, product_id), and Update / Delete / HardDelete
include every key column in the WHERE clause:
item := OrderItem{OrderID: 1001, ProductID: 42, Quantity: 3}
quark.For[OrderItem](ctx, client).Create(&item)
item.Quantity = 5
quark.For[OrderItem](ctx, client).Update(&item)
got, _ := quark.For[OrderItem](ctx, client).
Where("order_id", "=", 1001).
Where("product_id", "=", 42).
First()
fmt.Println(got.Quantity)
// => 5
Find(id) is for single-column keys; query composite keys with explicit
Where predicates as above.
Timestamps and soft deletes
A nullable *time.Time field named deleted_at turns on soft deletes: Delete
sets the timestamp instead of removing the row, and every read hides it
automatically.
type User struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email"`
DeletedAt *time.Time `db:"deleted_at"`
}
Three modifiers shift the default filter:
| Modifier | Filter | Use for |
|---|---|---|
| (default) | deleted_at IS NULL | Normal reads. |
WithTrashed() | none | Admin/audit listings that include trashed rows. |
OnlyTrashed() | deleted_at IS NOT NULL | A trash view, or a restore/purge UI. |
Unscoped() | none | Alias of WithTrashed. |
u := &User{Email: "x@y.z"}
quark.For[User](ctx, client).Create(u)
quark.For[User](ctx, client).Delete(u) // soft delete
live, _ := quark.For[User](ctx, client).Count()
all, _ := quark.For[User](ctx, client).WithTrashed().Count()
fmt.Println(live, all)
// => 0 1
Restore clears deleted_at for a row you've loaded. Restoring a row that is
already live affects zero rows instead of writing anything, so a misuse can't
corrupt live data:
trashed, _ := quark.For[User](ctx, client).OnlyTrashed().Find(42)
quark.For[User](ctx, client).Restore(&trashed)
For a permanent delete, use HardDelete (one entity) or DeleteBy /
DeleteBatch (by predicate or IDs — both are always hard deletes).
Partial updates and optimistic locking
Update is a partial update: it skips zero-value fields, so a half-filled
struct can't blank out a column with false, 0, or "". To write a zero value
on purpose, name the column with UpdateMap:
quark.For[User](ctx, client).
Where("id", "=", 7).
UpdateMap(map[string]any{"active": false, "score": 0})
To turn a series of free-form mutations into a precise UPDATE instead, load the
row with Track() and call Save — see Query Builder and the
zero-value note in Getting Started.
Optimistic locking
Add a numeric field tagged quark:"version" to opt into optimistic locking.
Every Update, UpdateFields, and Tracked.Save then bumps the version and
guards on its loaded value:
type Account struct {
ID int64 `db:"id" pk:"true"`
Owner string `db:"owner"`
Balance int64 `db:"balance"`
Version int64 `db:"version" quark:"version"`
}
a, _ := quark.For[Account](ctx, client).Find(42)
a.Balance = 150
_, err := quark.For[Account](ctx, client).Update(&a)
// emits: UPDATE "accounts" SET "balance" = $1, "version" = "version" + 1
// WHERE "id" = $2 AND "version" = $3
If another writer advanced the version since your Find, the predicate matches
no rows, nothing is written, and you get ErrStaleEntity:
if errors.Is(err, quark.ErrStaleEntity) {
// reload, replay, retry — or surface the conflict to the user.
}
// => stale conflict: true (when a concurrent writer won the race)
On success, Quark bumps the in-memory version so the next Update sees the new
value without a re-read. The version column is automatically NOT NULL, and one
model may carry the tag on only one field.
Validation
Quark validates before Create, Upsert, CreateBatch, and UpsertBatch,
using validator/v10 tags:
type Member struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email" validate:"required,email"`
Role string `db:"role" validate:"oneof=admin member viewer"`
}
err := quark.For[Member](ctx, client).Create(&Member{Email: "not-an-email", Role: "admin"})
fmt.Println(err != nil)
// => true (the invalid email is rejected before any SQL runs)
For rules that need code, add a Validate(context.Context) error method —
Quark calls it before tag validation:
func (m *Member) Validate(ctx context.Context) error {
if strings.HasSuffix(m.Email, "@example.invalid") {
return errors.New("reserved test domain")
}
return nil
}
Automatic timestamps
created_at and updated_at maintain themselves. Create fills both when they
are zero, and Update refreshes updated_at on every write, in UTC. The fields
must be time.Time mapped to exactly those column names; the scaffolded models
already qualify.
A value you set yourself before Create is kept, so imports and backdating keep
working. Timestamp hooks written for older versions also stay correct: they run
first, and the automatic stamping only touches values still at zero.
Lifecycle hooks
Add methods to run code around writes:
func (u *User) BeforeCreate(ctx context.Context) error {
u.Slug = slugify(u.Name) // derived fields, validation, defaults…
return nil
}
The full set is BeforeCreate/AfterCreate, BeforeUpdate/AfterUpdate,
BeforeDelete/AfterDelete, plus BeforeFind/AfterFind. Batch writes run the
Before* hooks per entity but not After*, and UpdateMap runs none (there's no
entity). Lifecycle Hooks has the exact matrix and the post-commit
semantics under transactions.
Rich column types
Beyond Go's primitives, Quark ships generic wrappers for the common cases. Each
implements sql.Scanner and driver.Valuer, so reads and writes use the
standard library's fast paths with no extra reflection.
Nullable columns — Nullable[T]
quark.Nullable[T] re-exports database/sql.Null[T] with friendlier
constructors. It's the recommended way to model a SQL-NULL-able column:
type Profile struct {
ID int64 `db:"id" pk:"true"`
Bio quark.Nullable[string] `db:"bio"`
Born quark.Nullable[time.Time] `db:"born"`
}
p := Profile{
Bio: quark.SomeOf("hello"), // set
Born: quark.NullOf[time.Time](), // SQL NULL
}
Migrate emits T's column type for you. The older *time.Time and
sql.NullString idioms still work, but Nullable[T] avoids the pointer's heap
allocation and reads more clearly at the call site — p.Bio.Valid rather than a
nil check.
Typed JSON — JSON[T]
quark.JSON[T] stores any JSON-round-trippable T (struct, map, slice,
primitive) and unmarshals it back on read:
type Settings struct {
Theme string `json:"theme"`
Volume int `json:"volume"`
}
type Pref struct {
ID int64 `db:"id" pk:"true"`
Settings quark.JSON[Settings] `db:"settings"`
}
p := Pref{Settings: quark.JSON[Settings]{V: Settings{Theme: "dark", Volume: 7}}}
quark.For[Pref](ctx, client).Create(&p)
Migrate picks the dialect-native column type: JSONB on PostgreSQL, JSON on
MySQL/MariaDB, TEXT on SQLite, NVARCHAR(MAX) on SQL Server, CLOB on Oracle.
Wrap it in Nullable[JSON[T]] when you need to tell SQL NULL apart from an empty
payload.
Typed arrays — Array[T]
quark.Array[T] is the clearer choice for a list column. It's stored as JSON
(same wire format and column type as JSON[[]T]), with list helpers in Go:
p := Post{Tags: quark.Array[string]{V: []string{"go", "orm"}}}
quark.For[Post](ctx, client).Create(&p)
fmt.Println(p.Tags.Len(), p.Tags.Slice())
// => 2 [go orm]
It is deliberately not a PostgreSQL-native TEXT[] wrapper: operators like
@> and array_agg won't fire on the JSON column behind it. For native arrays
with operators, drop to pgx/pgtype or RawQuery.
A nil V serializes to [], not null. Wrap it in Nullable[Array[T]] when
you need to tell SQL NULL apart from "valid but empty".
Raw slices and maps, ranges, IP addresses
A raw []string, []int64 or map[string]any field is stored as it is: on
PostgreSQL a slice of a scalar kind becomes a native array column (TEXT[],
BIGINT[], DOUBLE PRECISION[], BOOLEAN[]) and the value travels as the
array literal; on every other engine — and for maps and slices of structs
everywhere — the column is the dialect's JSON type and the value JSON text.
Array[T] stays the JSON-backed wrapper on every engine when you want the same
column shape on all six.
quark.Range[T] is a bounded interval — Lower, Upper, Bounds ("[)" by
default) and Empty — stored as TSTZRANGE, INT8RANGE, INT4RANGE or
NUMRANGE on PostgreSQL and as JSON elsewhere. A net.IP field is an INET
column on PostgreSQL and a text column elsewhere, bound and scanned as the
address you would type.
The operators PostgreSQL has for these types — @>, <@, && for containment
and overlap, >>, <<, >>=, <<= for networks — are accepted by Where on
PostgreSQL and refused with ErrUnsupportedFeature naming the engine anywhere
else, before any SQL is built. Nothing is rewritten into an approximation. To
ask whether a range contains a point, pass the point as the singleton range
Range[T]{Lower: p, Upper: p, Bounds: "[]"}: through a driver placeholder
PostgreSQL cannot tell a bare value from a range literal on the right of @>.
type Event struct {
ID int64 `db:"id" pk:"true"`
Tags []string `db:"tags"`
Window quark.Range[time.Time] `db:"window"`
Source net.IP `db:"source"`
}
quark.For[Event](ctx, client).Where("tags", "@>", []string{"go"}).List() // PostgreSQL only
at := time.Now()
quark.For[Event](ctx, client).Where("window", "@>", quark.Range[time.Time]{Lower: at, Upper: at, Bounds: "[]"}).List() // PostgreSQL only
quark.For[Event](ctx, client).Where("source", "<<", "10.0.0.0/8").List() // PostgreSQL only
Binary columns
A plain []byte maps to the native binary type — BYTEA (PostgreSQL),
BLOB (MySQL/MariaDB/SQLite/Oracle), VARBINARY(MAX) (SQL Server):
type File struct {
ID int64 `db:"id" pk:"true"`
Bytes []byte `db:"bytes"`
}
Custom types — RegisterTypeMapper
For your own value types (or shopspring/decimal.Decimal,
google/uuid.UUID, …), register a mapper at startup. It receives the dialect
name and the parsed TypeOptions and returns the SQL column type:
func init() {
quark.RegisterTypeMapper(reflect.TypeOf(uuid.UUID{}), func(dialect string, _ quark.TypeOptions) string {
switch dialect {
case "postgres":
return "UUID"
default:
return "VARCHAR(36)"
}
})
}
Pointer types are stripped before lookup, so this also covers *uuid.UUID.
A mapped primary key keeps its key: the mapper's type is taken verbatim and
PRIMARY KEY is appended unless the mapper wrote it itself. (No auto-increment
is inferred from a mapped type.)
You may not need the mapper for UUIDs at all: any 16-byte array — the shape of
google/uuid.UUID — gets UUID on PostgreSQL, CHAR(36) on MySQL and
MariaDB, VARCHAR2(36) on Oracle, NCHAR(36) on SQL Server and a declared
UUID on SQLite, with the value travelling in its text form through the type's
own Valuer / Scanner. SQL Server's UNIQUEIDENTIFIER is deliberately not
used: the driver scans it as sixteen bytes in the engine's own byte order,
which a Scanner expecting the RFC order reads as a different UUID.
The mapper only decides the DDL type. The round-trip still goes through
database/sql, so the type must implement sql.Scanner and driver.Valuer, or
be natively supported by the driver. A time.Duration mapper ships built in; it
emits BIGINT, or NUMBER(19) on Oracle.
UNIQUEIDENTIFIERSQL Server stores the first three groups of a GUID little-endian while
google/uuid is big-endian, so a UNIQUEIDENTIFIER round-trip hands back a
different, silently-wrong UUID. Map uuid.UUID to VARCHAR(36) /
NVARCHAR(36) instead (Quark's migrator already does this for string UUID
primary keys), or store the driver's own mssql.UniqueIdentifier type if you
truly need the native column.
Timezones
By default a time.Time passes through to the driver untouched. Two opt-in
knobs let you control the zone: quark.WithDefaultTZ(loc) sets a client-wide
fallback, and quark:"tz=Europe/Madrid" overrides a single column.
client, _ := quark.New("postgres", dsn, quark.WithDefaultTZ(time.UTC))
type Event struct {
ID int64 `db:"id" pk:"true"`
CreatedAt time.Time `db:"created_at"` // uses the client default (UTC)
LocalTime time.Time `db:"local_time" quark:"tz=Europe/Madrid"` // column override wins
}
Precedence is column tag → client default → pass-through.
The wire contract is always UTC. A zoned column is converted to UTC on the way to
the database and back to the configured location when scanned, so the tag
changes only how the field reads in Go — never what is stored. An invalid IANA
name fails fast at RegisterModel or Migrate with ErrInvalidTimezone, so a
typo breaks startup instead of some later query.
Relation fields
Relation fields use a rel tag instead of db, so they aren't treated as
columns. They're loaded with Preload and can be saved recursively:
type User struct {
ID int64 `db:"id" pk:"true"`
TeamID int64 `db:"team_id"`
Profile *Profile `rel:"has_one" join:"user_id"`
Posts []Post `rel:"has_many" join:"user_id"`
Team *Team `rel:"belongs_to" join:"team_id"`
}
See Relations for the full tag matrix and eager loading.
Inspecting metadata
GetModelMeta[T] returns the cached metadata — handy for tooling, diagnostics,
and tests:
meta := quark.GetModelMeta[Product]()
fmt.Println(meta.Table, meta.PK.Column, meta.FieldByCol["sku"].Index)
// => products id 1