Skip to content
Define models and save data

Define models and save data

Map structs to tables, and create, update and delete rows with timestamps, soft deletes and hooks.

Before you start

Connect to a database and create the tables with migrations.

Steps

1. Define the models

// Author writes posts.
type Author struct {
	db.Model        // id, created_at, updated_at
	Name     string `db:"name" json:"name"`
	Email    string `db:"email" json:"email"`

	Posts []Post `rel:"has_many" json:"posts,omitzero"` // posts.author_id; loaded with With or Load
}

// Post is a blog post. Deleting one only marks it deleted.
type Post struct {
	db.Model
	db.SoftDeletes            // deleted_at
	AuthorID       int64      `db:"author_id" json:"author_id"`
	Title          string     `db:"title" json:"title"`
	Body           string     `db:"body" json:"body"`
	Tags           []string   `db:"tags,json" json:"tags"` // stored as JSON text
	Views          int        `db:"views" json:"views"`
	PublishedAt    *time.Time `db:"published_at" json:"published_at"` // nullable

	Author *Author `rel:"belongs_to" json:"author,omitzero"` // by AuthorID
}

// AuthorCols and PostCols, the typed columns of the models, and
// AuthorRels and PostRels, their relations, are in models_gen.go, written
// by `go tool anetos gen` (or go generate).
//
//go:generate go tool anetos gen

(Copied from examples/database, region models.)

  • db.Model adds an auto-increment id and the created_at and updated_at timestamps. Embed db.Timestamps alone if the key is something else.
  • db.SoftDeletes adds deleted_at: deleting sets it, and queries skip those rows.
  • Columns are the db tags; untagged fields use their snake_case names (AuthorID → author_id). Fields with a rel tag are relations, not columns; other struct, pointer-to-struct and slice-of-struct fields without a tag are skipped.
  • The table is the snake_case plural of the type name (Post → posts, Category → categories). Add a TableName() string method to choose another.
  • Use pointers (or sql.Null[T]) for nullable columns, and db:"name,json" to store a value as JSON.
  • go tool anetos gen writes the typed columns (PostCols.Title) that queries use; see Generate typed columns.

All the tag options and naming rules are in the models reference.

2. Create, read, update, delete

// illustrative
post := Post{AuthorID: 1, Title: "Hello"}
err := db.Create(ctx, &post)      // sets post.ID, CreatedAt, UpdatedAt

post, err = db.Find[Post](ctx, 1) // db.ErrNotFound if there is no row

post.Title = "Hello, world"
err = db.Update(ctx, &post)       // writes every column; sets UpdatedAt

err = db.Save(ctx, &post)         // Create if ID is zero, else Update

err = db.Delete(ctx, &post)       // soft delete: sets DeletedAt
err = db.Restore(ctx, &post)
err = db.ForceDelete(ctx, &post)  // really deletes the row

Handlers can return db.ErrNotFound as it is: it becomes a 404.

3. Insert many rows, or upsert

// illustrative
err := db.CreateMany(ctx, posts) // one INSERT per batch; IDs set on PostgreSQL and SQLite

// Insert, or update price for SKUs that already exist (sku needs a unique index).
err = db.Upsert(ctx, prices, []string{"sku"}, "price")

4. Run code around saves (optional)

Implement hook methods on the model’s pointer type:

// illustrative
func (p *Post) BeforeSave(ctx context.Context) error {
	p.Title = strings.TrimSpace(p.Title)
	if p.Title == "" {
		return validate.Fail("title", "The title can't be blank.")
	}
	return nil
}

Hooks run for Create, Update, Save, Delete and (create hooks) CreateMany. A hook error stops the operation. Mass updates and deletes on a query don’t run hooks.

Complete example

examples/database is a small blog API using everything on this page.

How it works

The first use of a model type reads its fields and tags once and caches the result; later calls only copy values. Timestamps are set in UTC with microsecond precision, so what you get back from the database equals what you saved. See the data layer concept.

Coming from Laravel? db.Model and db.SoftDeletes play the role of Eloquent’s base model and SoftDeletes trait. Models don’t track dirty attributes: Update writes every column, and mass updates set exactly the columns you name.

Testing it

Make rows with factories in a anetostest app, call your functions with app.Context(), and check the table with anetostest.AssertDatabaseHas[T].

Common problems

SymptomCauseFix
converting NULL to string is unsupportedA nullable column mapped to a non-pointer fieldUse *string or sql.Null[string]
db: Post has a zero primary key; Create it firstUpdate or Delete on a row without an IDLoad the row first, or use Save
Wrong table name (persons for Person… it’s people)The pluralizer’s guessAdd a TableName() method
A field is never savedIt’s a struct or slice of structs without a db tagTag it (db:"meta,json" for JSON)
IDs are zero after CreateMany on MySQLMySQL can’t report keys for multi-row insertsReload the rows, or use Create per row

Next steps