Skip to main content
Version: 1.8.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".
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, mappersForwards precision/scale to DECIMAL emitters and TypeMapper.
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.

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".

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.

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