Getting Started
By the end of this page you'll have a small but complete users service: a model, its table, and every basic operation against it — insert, read, list, update, delete. It takes about ten minutes.
The examples run on SQLite, so there is nothing to install. The same code runs on PostgreSQL, MySQL, MariaDB, SQL Server, and Oracle by changing one string.
The // => comments show what each snippet prints, so you can follow along
as if you were typing into a shell.
Start with a model
A Quark model is a plain Go struct. The tags tell Quark how the struct maps to a table:
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"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt *time.Time `db:"deleted_at"`
}
That's the whole data layer. A few conventions are worth knowing up front, but none of them need configuration:
-
db:"..."is the column name. A field without adbtag is ignored entirely — never migrated, inserted, or scanned. -
pk:"true"marks the primary key. (No primary key tag? Quark falls back to the field taggeddb:"id".) -
The table name is inferred by pluralizing and snake-casing the struct:
User→users,APIKey→api_keys. Already have a table with a different name? Add one method:func (User) TableName() string { return "app_users" } -
A
*time.Timefield nameddeleted_atturns on soft deletes automatically. You'll see that pay off in a minute.
Want timestamps set for you on every write? Add lifecycle hooks — ordinary methods on the struct:
func (u *User) BeforeCreate(ctx context.Context) error {
now := time.Now()
u.CreatedAt, u.UpdatedAt = now, now
return nil
}
func (u *User) BeforeUpdate(ctx context.Context) error {
u.UpdatedAt = time.Now()
return nil
}
Connect
quark.New takes a driver name and a data source, and gives you back a Client:
client, err := quark.New("sqlite", "file:quark.db?cache=shared")
if err != nil {
log.Fatal(err)
}
defer client.Close()
Quark detects the dialect from the driver name — pass "postgres", "mysql",
"sqlserver", etc. to talk to a real database instead. That's the only line that
changes between engines.
log/slogThe default logger prints one informational line when the client starts
(INFO quark client initialized dialect=sqlite ...), plus the occasional WARN
when Quark wants your attention — for example when an update skips a zero value
(more on that below). Pass quark.WithLogger
to route these through your own logger, or to silence them.
Create the table
For local development and tests, Migrate creates any missing tables (and
many-to-many join tables) from your models:
if err := client.Migrate(ctx, &User{}); err != nil {
log.Fatal(err)
}
That's enough to start building. Evolving a live schema without losing data is
a different job, handled by Sync or versioned migrations — see
Migrations — but you don't need either yet.
Insert a row — and watch the ID come back
Every query starts from quark.For[Model](ctx, client). Here's a create:
u := User{Email: "alice@example.com", Name: "Alice", Active: true}
if err := quark.For[User](ctx, client).Create(&u); err != nil {
log.Fatal(err)
}
fmt.Println("new id:", u.ID)
// => new id: 1
Create does four things in order: it validates the struct (that
validate:"required,email" tag is enforced here), runs your BeforeCreate
hook, inserts the row, and writes the generated primary key back into u.
That last step is why u.ID reads 1 afterwards.
Read it back
For a lookup by primary key, use Find:
alice, err := quark.For[User](ctx, client).Find(1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", alice)
// => {ID:1 Email:alice@example.com Name:Alice Active:true CreatedAt:... UpdatedAt:... DeletedAt:<nil>}
The row comes back as a fully typed User — no casts, no interface{}, no manual
Scan.
Query a list
Chain Where, OrderBy, and Limit, then call List:
active, err := quark.For[User](ctx, client).
Where("active", "=", true).
Where("name", "LIKE", "A%").
OrderBy("created_at", "DESC").
Limit(20).
List()
if err != nil {
log.Fatal(err)
}
fmt.Println(len(active), "active users")
fmt.Println(active[0].Name)
// => 1 active users
// => Alice
Each builder method returns a new query, so you can keep a base query in a variable and branch off it without surprises:
base := quark.For[User](ctx, client).Where("active", "=", true)
count, _ := base.Count()
page, _ := base.OrderBy("name", "ASC").Limit(10).List()
fmt.Println(count, "match;", len(page), "on this page")
// => 1 match; 1 on this page
If you forget Limit, List applies a safe default of 100 rows. For genuinely
large result sets, stream with Iter or Cursor instead of loading everything
into memory.
Update a row
Change a field on the struct and call Update. It returns the number of rows
affected:
alice.Name = "Alice Walker"
rows, err := quark.For[User](ctx, client).Update(&alice)
if err != nil {
log.Fatal(err)
}
fmt.Println(rows, "row updated")
// => 1 row updated
Update is partial: it skips zero values, so a half-filled struct can't
accidentally blank out a column with false, 0, or "". Whenever a scalar
zero is skipped this way Quark logs a WARN, so it is never silent. (A nil
pointer — an unset deleted_at, say — is the expected "absent" case, and stays
quiet.)
To write a zero value on purpose, name the column with UpdateMap:
rows, _ = quark.For[User](ctx, client).
Where("id", "=", alice.ID).
UpdateMap(map[string]any{"active": false})
fmt.Println(rows, "row deactivated")
// => 1 row deactivated
UpdateMap requires a Where clause — there's no way to fat-finger a
full-table update.
Delete a row (softly)
Because User has a deleted_at column, Delete performs a soft delete —
the row stays in the table but disappears from normal queries:
quark.For[User](ctx, client).Delete(&alice)
_, err = quark.For[User](ctx, client).Find(alice.ID)
fmt.Println(errors.Is(err, quark.ErrNotFound))
// => true (soft-deleted rows are hidden by default)
Need to see them anyway? Add Unscoped:
all, _ := quark.For[User](ctx, client).Unscoped().List()
fmt.Println(len(all), "rows including soft-deleted")
// => 1 rows including soft-deleted
To delete for real, use HardDelete (one entity) or DeleteBy (by predicate,
no row loaded first):
quark.For[User](ctx, client).Where("active", "=", false).DeleteBy()
Insert-or-update in one call
Upsert creates a row when it's new and updates it when it already exists —
what an import or an idempotent command usually wants:
incoming := User{Email: "alice@example.com", Name: "Alice W.", Active: true}
err = quark.For[User](ctx, client).Upsert(
&incoming,
[]string{"email"}, // conflict target (a unique key)
[]string{"name", "active"}, // columns to update on conflict
)
Quark emits the right ON CONFLICT, ON DUPLICATE KEY, or MERGE for whatever
engine you're on — you write this once.
When something goes wrong
Quark returns sentinel errors you can match with errors.Is, so your handlers
stay readable:
user, err := quark.For[User](ctx, client).Find(999)
switch {
case err == nil:
fmt.Println("found:", user.Name)
case errors.Is(err, quark.ErrNotFound):
fmt.Println("no such user")
// => no such user
case errors.Is(err, quark.ErrConstraintViolation):
fmt.Println("duplicate email or bad foreign key")
default:
log.Fatal(err)
}
One safety net worth knowing: a malformed column or table name is caught
before the query reaches the database, and comes back as
quark.ErrInvalidIdentifier. It's a fast structural check, not a spell-checker
— a well-formed name for a column that doesn't exist still fails at the
database, as usual.
The complete program
Here's everything above as one runnable file:
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/jcsvwinston/quark"
_ "github.com/jcsvwinston/quark/drivers/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"`
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, u.UpdatedAt = now, 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)
}
// Create
u := User{Email: "alice@example.com", Name: "Alice", Active: true}
if err := quark.For[User](ctx, client).Create(&u); err != nil {
log.Fatal(err)
}
fmt.Println("new id:", u.ID) // => new id: 1
// Read
alice, _ := quark.For[User](ctx, client).Find(u.ID)
fmt.Println(alice.Name) // => Alice
// Update
alice.Name = "Alice Walker"
rows, _ := quark.For[User](ctx, client).Update(&alice)
fmt.Println(rows, "row updated") // => 1 row updated
// List
active, _ := quark.For[User](ctx, client).
Where("active", "=", true).
OrderBy("created_at", "DESC").
Limit(20).
List()
fmt.Println(len(active), "active users") // => 1 active users
// Soft delete
quark.For[User](ctx, client).Delete(&alice)
_, err = quark.For[User](ctx, client).Find(alice.ID)
fmt.Println(errors.Is(err, quark.ErrNotFound)) // => true
}
Run it with go run . and you'll see each line print as commented above.
Where to next
| You want to… | Read |
|---|---|
| Use composite keys, validation, hooks, rich types, timezones | Modeling |
| Filter, paginate, join, aggregate, stream | Query Builder |
| Load related rows and save associations | Relations |
| Create, update, upsert, or delete in bulk | Batch Operations |
| Evolve a live schema safely | Migrations |