Getting Started
This guide builds the smallest useful Quark application. It uses SQLite so you
can run it locally without containers, but the same model and query code works
with PostgreSQL, MySQL, MariaDB, SQL Server, and Oracle by changing the driver
name passed to quark.New.
The Mental Model
Quark has three main pieces:
| Piece | Role |
|---|---|
Client | Owns *sql.DB, dialect, limits, middleware, observers, cache, and schema helpers. |
Model | A normal Go struct with db, pk, validation, and relation tags. |
Query[T] | An immutable typed builder created with quark.For[T](ctx, client). |
Reads and writes start from quark.For[Model](ctx, client). Builder methods
such as Where, OrderBy, Limit, Preload, and Cache return a cloned
query, so you can reuse base queries safely.
Complete Example
package main
import (
"context"
"errors"
"log"
"time"
"github.com/jcsvwinston/quark"
_ "modernc.org/sqlite"
)
type User struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email" quark:"unique,not_null" validate:"required,email"`
Name string `db:"name" quark:"not_null"`
Active bool `db:"active" default:"1"`
Score int `db:"score" default:"0"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt *time.Time `db:"deleted_at"`
}
func (u *User) BeforeCreate(ctx context.Context) error {
now := time.Now()
u.CreatedAt = now
u.UpdatedAt = now
return nil
}
func (u *User) BeforeUpdate(ctx context.Context) error {
u.UpdatedAt = time.Now()
return nil
}
func main() {
ctx := context.Background()
client, err := quark.New("sqlite", "file:quark.db?cache=shared")
if err != nil {
log.Fatal(err)
}
defer client.Close()
if err := client.Migrate(ctx, &User{}); err != nil {
log.Fatal(err)
}
user := User{
Email: "alice@example.com",
Name: "Alice",
Active: true,
}
if err := quark.For[User](ctx, client).Create(&user); err != nil {
log.Fatal(err)
}
found, err := quark.For[User](ctx, client).Find(user.ID)
if err != nil {
log.Fatal(err)
}
found.Name = "Alice Walker"
if _, err := quark.For[User](ctx, client).Update(&found); err != nil {
log.Fatal(err)
}
active, err := quark.For[User](ctx, client).
Where("active", "=", true).
OrderBy("created_at", "DESC").
Limit(20).
List()
if err != nil {
log.Fatal(err)
}
log.Printf("active users: %d", len(active))
if _, err := quark.For[User](ctx, client).Delete(&found); err != nil {
log.Fatal(err)
}
_, err = quark.For[User](ctx, client).Find(found.ID)
if errors.Is(err, quark.ErrNotFound) {
log.Println("soft-deleted rows are hidden by default")
}
}
Create a Client
client, err := quark.New("sqlite", "file:quark.db?cache=shared")
if err != nil {
return err
}
defer client.Close()
The dialect is auto-detected from the driver name. Use WithDialect to override
when the driver name is ambiguous (e.g. registering a custom dialect under
pgx).
Define a Model
type User struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email" quark:"unique,not_null" validate:"required,email"`
Name string `db:"name" quark:"not_null"`
Active bool `db:"active" default:"1"`
DeletedAt *time.Time `db:"deleted_at"`
}
The db tag is the persisted column name. Fields without db are ignored by
Migrate, inserts, updates, and scans. pk:"true" marks the primary key. If no
pk:"true" tag exists, Quark falls back to a field tagged db:"id".
Table names are inferred by pluralizing and snake-casing the struct name:
User maps to users, APIKey maps to api_keys. Implement TableName()
when the database already uses another name.
func (User) TableName() string {
return "app_users"
}
Create Tables
if err := client.Migrate(ctx, &User{}); err != nil {
return err
}
Migrate creates missing tables and many-to-many join tables. It is intentionally
simple and useful for development, tests, and initial schema creation. For live
schema evolution, use Sync or versioned migrations as described in
Migrations and Sync.
Insert Rows
user := User{
Email: "alice@example.com",
Name: "Alice",
Active: true,
}
if err := quark.For[User](ctx, client).Create(&user); err != nil {
return err
}
fmt.Println(user.ID)
Create validates the model, runs BeforeCreate, inserts the row, writes back
the primary key when the dialect supports it, then runs AfterCreate.
Query Rows
Use Find for simple primary keys:
user, err := quark.For[User](ctx, client).Find(42)
Use Where, OrderBy, and Limit for lists:
users, err := quark.For[User](ctx, client).
Where("active", "=", true).
Where("score", ">=", 100).
OrderBy("created_at", "DESC").
Limit(50).
List()
List applies a safe default limit of 100 when you forget to call Limit.
Use Iter or Cursor when you intentionally want to stream a large result set.
Update Rows
user.Name = "Alice Walker"
rows, err := quark.For[User](ctx, client).Update(&user)
Update is partial: zero values are skipped. This prevents a sparse struct from
accidentally overwriting columns with false, 0, "", or nil.
When you do need to write a zero value, use UpdateMap with an explicit
predicate:
rows, err = quark.For[User](ctx, client).
Where("id", "=", user.ID).
UpdateMap(map[string]any{
"active": false,
"score": 0,
})
UpdateMap requires a Where clause to avoid accidental full-table updates.
Delete Rows
If a model has a deleted_at column, Delete performs a soft delete:
rows, err := quark.For[User](ctx, client).Delete(&user)
Soft-deleted rows are hidden from normal Find, First, List, Count, and
aggregate operations. Use Unscoped to include them:
allUsers, err := quark.For[User](ctx, client).Unscoped().List()
Use HardDelete to permanently remove a single entity:
rows, err = quark.For[User](ctx, client).HardDelete(&user)
Use DeleteBy when you want a hard delete by predicate without loading each row:
rows, err = quark.For[User](ctx, client).
Where("active", "=", false).
DeleteBy()
DeleteBy requires a Where clause. In the current API it performs a hard
delete, even if the model has deleted_at.
Upsert
Use Upsert when an external feed or idempotent command should insert a row if
it does not exist, or update selected columns if it conflicts.
incoming := User{
Email: "alice@example.com",
Name: "Alice",
Active: true,
}
err := quark.For[User](ctx, client).Upsert(
&incoming,
[]string{"email"},
[]string{"name", "active"},
)
The conflict columns must match a primary key or unique constraint in the
database. Quark generates the appropriate ON CONFLICT, ON DUPLICATE KEY, or
MERGE statement for the selected dialect.
Handle Common Errors
user, err := quark.For[User](ctx, client).Find(42)
switch {
case err == nil:
_ = user
case errors.Is(err, quark.ErrNotFound):
return nil
case errors.Is(err, quark.ErrConstraintViolation):
return fmt.Errorf("duplicate or invalid related row: %w", err)
case errors.Is(err, quark.ErrInvalidQuery):
return fmt.Errorf("query rejected before execution: %w", err)
default:
return err
}
ErrInvalidQuery usually means SQLGuard rejected a column, table, operator, raw
query, or safety condition before the statement reached the driver.
Next Steps
| Need | Read |
|---|---|
| Model tags, composite keys, validation, hooks | Modeling |
| Filters, scopes, pagination, streaming | Query Builder |
| Eager loading and association persistence | Relations |
| Bulk create, update, upsert, delete | Batch Operations |
| Schema evolution | Migrations and Sync |