Skip to main content
Version: 1.15.0

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:

StructDefault table
Userusers
Categorycategories
APIKeyapi_keys
Addressaddresses

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:

TagDoes
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)
TagRead byPurpose
db:"column,size=512"Migrate, SyncSets VARCHAR/CHAR length (NVARCHAR/VARCHAR2 per dialect).
db:"column,precision=18,scale=4"Migrate, Sync, mappersTurns 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:"-"parserIgnores the field.
default:"value"MigrateEmits DEFAULT value verbatim; bool defaults are normalized per dialect (below).
nullable:"false"MigrateAlso emits NOT NULL.
quark:"version"Update, Tracked.SaveOptimistic-locking column (see below).
quark:"rename:old_col"SyncRenames 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.

If you are coming from Nucleus's pkg/model

Both read a tag called db, and they do not agree on what is inside it.

QuarkNucleus pkg/model
what the tag holdsthe column name, plus optionsdirectives
separatorcommasemicolon
primary keya separate pk:"true" tagdb:"pk"
column namedb:"email"db:"column:email"
not null / uniquequark:"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:

ModifierFilterUse for
(default)deleted_at IS NULLNormal reads.
WithTrashed()noneAdmin/audit listings that include trashed rows.
OnlyTrashed()deleted_at IS NOT NULLA trash view, or a restore/purge UI.
Unscoped()noneAlias 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.

On SQL Server, store UUIDs as text, not UNIQUEIDENTIFIER

SQL 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