Associations
LiteORM’s orm front-end models four kinds of association — has-many, has-one, belongs-to, and many-to-many (plus polymorphic ownership as a variant of has-many / has-one) — as struct fields, and loads them with one explicit, batched call. There is no lazy loading: you eager-load a relation when you want it, or you simply don’t have the data. That trade buys you predictable, N+1-safe queries — loading a relation is always exactly one query, never one-per-parent.
For exhaustive API detail, see the reference at pkg.go.dev/liteorm.org/orm.
Declaring relations
Section titled “Declaring relations”You declare a relation by giving a model a field whose type is another model (or a slice of one). The kind is inferred from the shape, and the foreign key is inferred by convention — with a hard error if it can’t be found, so a typo never becomes silent wrong SQL.
type Author struct { ID int64 Name string Email string `orm:"email,unique"` Posts []Post // has-many: the FK author_id lives on Post}
func (Author) TableName() string { return "authors" }
type Post struct { ID int64 AuthorID int64 `orm:"author_id"` Title string Slug string `orm:"slug,unique"` Author *Author // belongs-to: the FK author_id lives on Post Tags []Tag `orm:"m2m:post_tags"` // many-to-many via post_tags Comments []Comment // has-many: the FK post_id lives on Comment}
func (Post) TableName() string { return "posts" }Has-many
Section titled “Has-many”A slice field whose element is another model: Author.Posts []Post. The foreign key lives on the target (post.author_id) and references the owner’s primary key. By convention the key is <owner_type>_id — here author_id. If that column doesn’t exist on the target, Load returns an error telling you to add it or set orm:"fk:<col>".
Belongs-to
Section titled “Belongs-to”A single-model field (often a pointer): Post.Author *Author. The foreign key lives on the owner (post.author_id) and references the target’s primary key. The same <target_type>_id convention applies, overridable with fk / references.
Has-one
Section titled “Has-one”Also a single-model field (User.Profile *Profile), but the foreign key lives on the target (profile.user_id) and references the owner’s primary key — the mirror image of belongs-to. By convention the key is <owner_type>_id.
Has-one and belongs-to have the same Go shape (a non-slice struct or pointer), so LiteORM tells them apart by where the foreign key is: it looks for the key on the owner first (belongs-to), and falls through to the target (has-one) when the owner doesn’t carry it — the same owner-first resolution gorm uses. If neither side has the key, you get a hard error that names both columns it looked for. Override the column with fk (and the referenced key with references); the override is read against whichever side owns the key.
Many-to-many
Section titled “Many-to-many”A slice field tagged with the junction table: Post.Tags []Tag \orm:“m2m:post_tags”`. The junction links the two primary keys; by convention its columns are <owner_type>_idand<target_type>_id (post_id, tag_id). AutoMigrate` creates the junction table for you when it doesn’t exist.
Polymorphic (has-many / has-one)
Section titled “Polymorphic (has-many / has-one)”When one table is owned by several owner types — Toy rows that belong to a User or a Pet — tag the relation polymorphic. The target carries two columns instead of one: an owner id and an owner type, so a single query can tell which kind of owner a row belongs to.
type Toy struct { ID int64 Name string OwnerID sql.NullInt64 `orm:"owner_id"` // nullable: detaching nulls it OwnerType string `orm:"owner_type"` // "users" or "pets"}
type User struct { ID int64 Toys []Toy `orm:"polymorphic:Owner"` // toys.owner_id + toys.owner_type}
type Pet struct { ID int64 Toys []Toy `orm:"polymorphic:Owner"`}polymorphic:Owner derives the two columns from the Owner prefix — owner_id and owner_type — and the type value written for each owner defaults to that owner’s table name (users, pets). Override the columns with polymorphicId / polymorphicType and the constant with polymorphicValue; the gorm spellings are read too. Loading, Count, and the Assoc writes are all scoped to the owner type automatically, so a user’s Load never sees a pet’s toys. A slice field is polymorphic has-many; a single struct/pointer field is polymorphic has-one.
This is the forward direction (owner → its polymorphic children), which covers the common case. The inverse — a Toy.Owner field resolving back to either a User or a Pet at runtime — is out of scope: LiteORM’s generics-first, no-runtime-dispatch design doesn’t model it cleanly. Load the owner explicitly by its concrete type.
Overriding the inferred keys
Section titled “Overriding the inferred keys”When your columns don’t follow the convention, override per relation with tags:
orm:"fk:<col>"— the foreign-key column.orm:"references:<col>"— the referenced (usually primary-key) column.
The values may be column names or Go field names. gorm foreignKey / references / many2many tags are read too.
Loading a relation
Section titled “Loading a relation”orm.Load[Parent, Child](ctx, sess, parents, fieldName) eager-loads one relation for a slice of parents in a single batched query, then assigns the results back onto each parent’s field. It’s N+1-safe by construction: one Load call is one query regardless of how many parents you pass.
posts := orm.NewRepo[Post](db)all, _ := posts.Find(ctx)
orm.Load[Post, Author](ctx, db, all, "Author") // belongs-to: one IN query on author idsorm.Load[Post, Tag](ctx, db, all, "Tags") // many-to-many: one JOIN queryorm.Load[Post, Comment](ctx, db, all, "Comments") // has-many: one IN query on post ids
for _, p := range all { fmt.Printf("%q by %s — %d tags, %d comments\n", p.Title, p.Author.Name, len(p.Tags), len(p.Comments))}fieldName is the Go field name on the parent ("Author", "Tags", "Comments"), and the two type parameters are the parent and the child model — the child must match the relation’s target type, or Load returns an error.
It works in either direction — to load an author’s posts, make the author the parent:
authors := []Author{ada}orm.Load[Author, Post](ctx, db, authors, "Posts")fmt.Printf("Ada has %d posts\n", len(authors[0].Posts))To narrow or order the loaded children, pass orm.LoadWhere / orm.LoadOrderBy — the filter and order apply to the single batched query, so it stays N+1-safe:
// each author's published posts, newest first — still one queryorm.Load[Author, Post](ctx, db, authors, "Posts", orm.LoadWhere("published_at IS NOT NULL"), orm.LoadOrderBy("published_at DESC"))These options are for the foreign-key relations (has-many, has-one, belongs-to); filtering a many-to-many load is a clear error for now. They constrain the one fetch — they do not impose a per-parent limit (a true “top N per parent” needs a window/lateral query, which LiteORM doesn’t generate for eager loads yet).
Nested loading
Section titled “Nested loading”orm.LoadPath[Root](ctx, sess, roots, "Author.Company") walks a dotted relation path, running exactly one batched query per segment — so a two-level path is two queries total, never N+1. Each segment is a Go relation field name on the type the previous segment produced.
posts := orm.NewRepo[Post](db)all, _ := posts.Find(ctx)
// Each post's author, then each of those authors' company — two queries.orm.LoadPath[Post](ctx, db, all, "Author.Company")To plan several paths at once, use the fluent Preloader — each path still costs one query per segment:
orm.NewPreloader[Post](db). With("Author"). With("Tags"). With("Comments.Author"). Load(ctx, all)A self-referential relation may repeat in a path for a bounded-depth tree load — the depth is exactly the number of segments, so there’s no unbounded recursion:
// load a category, its children, and its grandchildren — three queriesorm.LoadPath[Category](ctx, db, roots, "Children.Children")When you’d rather drive the levels yourself, chain single Load calls off the slice each level produces; the cost is identical (one query per level).
Writing associations
Section titled “Writing associations”orm.Assoc[Owner, Target](sess, fieldName, &owner) opens a typed write handle over any relation whose foreign key is not on the owner — has-many, has-one, or many-to-many — with the operations you’d reach for: Append, Delete, Replace, Clear, and Count. Create the rows on both sides first (so they have primary keys); the handle links them, it never cascade-saves a graph.
tags := orm.NewRepo[Tag](db)goTag := Tag{Name: "golang"}dbTag := Tag{Name: "databases"}tags.Create(ctx, &goTag)tags.Create(ctx, &dbTag)
rel, _ := orm.Assoc[Post, Tag](db, "Tags", &p1)rel.Append(ctx, &goTag, &dbTag) // link both tags to p1n, _ := rel.Count(ctx) // 2rel.Delete(ctx, &goTag) // unlink onerel.Replace(ctx, &dbTag) // the set becomes exactly {dbTag}rel.Clear(ctx) // unlink allThe relation kinds behave as you’d expect:
- Many-to-many —
Appendinserts junction rows (idempotent: re-linking an existing pair is a no-op),Deleteremoves them,Clearremoves every link for this owner. The target rows are never touched. - Has-many —
Appendpoints each target’s foreign key at the owner;DeleteandCleardetach by setting that foreign key back toNULL(so the column must be nullable). They never delete target rows — removing the rows themselves is aRepo.Delete, stated explicitly. - Has-one — the same foreign-key path as has-many, but to-one:
Replace(target)is the natural setter (it detaches the previous target and points the new one at the owner),Cleardetaches it, andCountis 0 or 1. - Polymorphic has-many / has-one — as above, but
Appendalso stamps the owner-type column, andDelete/Clear/Countare scoped to it, so writes for one owner type never touch another’s rows.
Append writes the foreign key back into the in-memory target structs for has-many and has-one. To refresh the owner’s field after any write, call orm.Load with the same field name. Belongs-to is a single foreign key on the owner, not managed here — set that field and Update the owner instead; Assoc returns an error for it.
orm.Attach / orm.Detach remain as the lower-level many-to-many link/unlink primitives (Append / Delete are built on them); reach for Assoc for the full surface.
Where to next
Section titled “Where to next”- The orm front-end — models, tags, the repository.
- Hooks — run logic around writes that touch related data.
- Transactions — attach links and create related rows atomically.