Relationships — one-to-one, one-to-many, many-to-many

belongs_to + many_to_many cover every cardinality. Three runnable examples.

12 minmedium

Real apps have entities that point at each other. A User has one Profile; a Contact belongs to a Group; a Post has many Tags. This lesson covers all three relationship cardinalities Grit supports — one-to-one, one-to-many, and many-to-many — using just two field types: belongs_to (with or without a unique constraint) and many_to_many. Each section is a complete, runnable example you can drop into a fresh Grit project.

The three cardinalities at a glance

CardinalityExampleField specDB shape
one-to-oneUser ↔ Profilebelongs_to + uniqueFK on child with uniqueIndex
one-to-manyGroup → Contactsbelongs_toFK on child, regular index
many-to-manyPost ↔ Tagsmany_to_manyAuto-generated join table

Example 1 — One-to-many: Group → many Contacts

Imagine a CRM. Every Contact belongs to exactly one Group (Clients, Leads, Vendors), and every Group has many Contacts. That's the textbook one-to-many — modelled in Grit with one belongs_to field on the child (Contact), not two declarations.

Group Contact
───── ───────────────
id (UUID) ←─── group_id (FK to Group)
name name
description email
phone

Step 1 — Generate the parent first (Group)

Order matters: generate Group before Contact, so the Contact resource's belongs_to field has something to point at.

Terminal
$grit generate resource Group \
$ --fields "name:string:unique,description:text:optional"
$grit migrate

That writes the same 8 files you saw in Lesson 3 — model, service, handler, routes, schema, type, hook, admin page. Now we add the child.

Step 2 — Generate the child with belongs_to

Terminal
$grit generate resource Contact \
$ --fields "name:string,email:string:unique,phone:string:optional,group:belongs_to:Group"
$grit migrate

Three colons in the relationship spec, each meaningful:

group : belongs_to : Group
└──┬─┘ └────┬────┘ └──┬──┘
│ │ │
│ │ └── Related model. PascalCase, singular. Must already exist.
│ │
│ └── Type. Tells Grit to make this a foreign key, not a plain string.
└── Field name. Convention: name it after the relation, NOT "group_id".
The generator appends "_id" automatically when writing the column.
Shortcut: if the field name matches the model name lowercased, you can drop the third part. group:belongs_to alone infers :Group. Be explicit when the field name diverges (e.g. parent:belongs_to:Group).

What the generator produced — Contact side

Open apps/api/internal/models/contact.go after regenerating and you'll see a new column plus an eager-load association:

apps/api/internal/models/contact.go (excerpt)
type Contact struct {
ID string `gorm:"primarykey;size:36" json:"id"`
Name string `gorm:"size:255" json:"name" binding:"required"`
Email string `gorm:"size:255;uniqueIndex" json:"email" binding:"required"`
Phone string `gorm:"size:255" json:"phone"`
// Foreign key column — what GORM stores in the contacts table.
GroupID string `gorm:"size:36;index" json:"group_id" binding:"required"`
// Association — populated when you call .Preload("Group").
Group *Group `json:"group,omitempty"`
Version int `gorm:"not null;default:1" json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}

Two fields for the same relationship — that's the GORM idiom:

  • GroupID string — the actual column on the contacts table. UUID string, indexed. This is what Create/Update endpoints accept and what the database stores.
  • Group *Group — the populated association. Empty by default; only loaded when the service does db.Preload("Group"). The generator's List and GetByID methods preload it for you.

The frontend types stay clean

packages/shared/src/types/contact.ts
export interface Contact {
id: string;
name: string;
email: string;
phone: string;
group_id: string;
group?: Group; // present when preloaded by the API
version: number;
created_at: string;
updated_at: string;
}

The admin form gets a dropdown

Open the regenerated apps/admin/app/resources/contacts/page.tsx and you'll see:

apps/admin/app/resources/contacts/page.tsx (excerpt)
form: [
{ name: "name", type: "text", label: "Name", required: true },
{ name: "email", type: "text", label: "Email", required: true },
{ name: "phone", type: "text", label: "Phone" },
{
name: "group_id",
type: "relationship-select",
label: "Group",
relation: { model: "groups", labelField: "name" },
required: true,
},
],

The relationship-select field is a server-backed dropdown — it hits GET /api/groups, lists each group by its name field, and writes the selected group's UUID into group_id. No glue needed.

The list page shows the parent name

apps/admin/app/resources/contacts/page.tsx (excerpt)
columns: [
{ key: "name", label: "Name", sortable: true, format: "text" },
{ key: "email", label: "Email", sortable: true, format: "text" },
{ key: "group.name", label: "Group", format: "text" }, // dotted path reads from preload
{ key: "created_at", label: "Created", format: "relative" },
],

Showing "has many" on the parent — a manual addition

The generator only writes the child side of belongs_to. Group doesn't automatically know about its contacts. That's deliberate — listing contacts on the Group is usually a separate API call (paginated, filtered, searchable), not a preload.

Two ways to add the "has many" side:

Option A — Add a Contacts association to Group manually

If you want GET /api/groups/:id to embed the contact list, add the field by hand:

apps/api/internal/models/group.go
type Group struct {
ID string `gorm:"primarykey;size:36" json:"id"`
Name string `gorm:"size:255;uniqueIndex" json:"name"`
Description string `gorm:"type:text" json:"description"`
// Added manually — has-many. GORM infers the FK is "group_id" on Contact.
Contacts []Contact `json:"contacts,omitempty"`
// auto-generated boilerplate stays the same
}

Then in the service for GetByID: db.Preload("Contacts").First(&group, "id = ?", id). Run grit sync and the TS type picks up contacts?: Contact[].

Option B — Filter the existing endpoint (recommended)

Use the auto-generated GET /api/contacts?group_id=:id endpoint. The generator wires a search/filter clause on every column, including FKs:

Terminal
$curl "http://localhost:8080/api/contacts?group_id=01HX...&page=1&page_size=20" \
$ -H "Authorization: Bearer $TOKEN"

Cleaner separation — Group endpoints handle Groups, Contact endpoints handle Contacts. Easier to paginate, easier to cache.

Example 2 — One-to-one: User ↔ Profile

One-to-one is just one-to-many with a unique constrainton the foreign key. Pick the side that's "optional / added later" (Profile, Settings, KycRecord) as the child and put the FK there. Each Profile points at one User; the unique index guarantees no User has more than one Profile.

Because the inline --fields syntax doesn't accept :unique on relationship fields, this case is a great fit for the YAML long-form (covered in the next lesson):

profile.yaml
name: Profile
fields:
- name: user
type: belongs_to
related_model: User
required: true
unique: true # ← this is what turns it into one-to-one
- name: bio
type: text
- name: avatar
type: string # URL field — auto-becomes VARCHAR(500)
- name: twitter_handle
type: string
required: false
Terminal
$grit generate resource Profile --from profile.yaml
$grit migrate

The generated Go model is almost identical to the one-to-many case — but the GORM tag is different:

apps/api/internal/models/profile.go (excerpt)
type Profile struct {
ID string `gorm:"primarykey;size:36" json:"id"`
Bio string `gorm:"type:text" json:"bio"`
Avatar string `gorm:"size:500" json:"avatar"`
// Note the uniqueIndex — that's what makes it 1-to-1.
UserID string `gorm:"size:36;index;uniqueIndex" json:"user_id" binding:"required"`
User *User `json:"user,omitempty"`
// auto-generated boilerplate
}

Try inserting two profiles for the same user — the second insert fails with a UNIQUE constraint failed error. That's the database guaranteeing the relationship rather than your app code having to check.

Which side gets the FK? The side that's optional or added later. A User is created on sign-up; a Profile is filled in afterwards — so Profile holds the FK. Same logic for User → Settings, Order → Receipt, etc. The other side (User) doesn't need any changes — it stays oblivious to the optional companion.

Loading the pair

To get a User with their Profile embedded, query from the Profile side and preload, or add a HasOne association on User manually:

// In services/user.go — add a method that joins the optional Profile.
func (s *UserService) GetWithProfile(id string) (*models.User, error) {
var u models.User
err := s.db.Preload("Profile").First(&u, "id = ?", id).Error
return &u, err
}

And add the association field to models/user.go:

type User struct {
// ...existing fields...
Profile *Profile `gorm:"foreignKey:UserID" json:"profile,omitempty"`
}

Run grit sync after the manual addition — the TS type picks up the optional profile?: Profile.

Example 3 — Many-to-many: Post ↔ Tags

Use many_to_many when an entity has many related items and each related item can belong to many of these entities. The classic case: a Post has many Tags, and each Tag is on many Posts.

Terminal
$grit generate resource Tag \
$ --fields "name:string:unique,slug:slug:name,color:string:optional"
$grit generate resource Post \
$ --fields "title:string,slug:slug,body:richtext,tags:many_to_many:Tag"
$grit migrate

The field spec works the same:

tags : many_to_many : Tag
└─┬─┘ └─────┬──────┘ └─┬─┘
│ │ │
│ │ └── Related model name. REQUIRED for many_to_many (unlike belongs_to).
│ │
│ └── Type. Creates a join table behind the scenes.
└── Field name. Usually plural. Becomes a []string of UUIDs in Go.

What you get:

  • A join table named post_tags with post_id + tag_id columns, indexed both ways.
  • Post.Tags is []string on the Go side (the array of related UUIDs) and string[] in TypeScript.
  • The admin Form gets a multi-relationship-select — a multi-select dropdown backed by GET /api/tags.

Picking the right relationship

If…UseWhere to put it
Each parent has at most one child (User ↔ Profile, Order ↔ Receipt)belongs_to + uniqueOn the "optional" side — uses YAML (inline can't set unique on belongs_to)
Child has exactly one parent (Contact → Group, Order → Customer)belongs_toOn the child
Parent owns N children, children are not shared (BlogPost → Comment)belongs_to on the childOn the child + filter ?post_id=
Both sides can have many of each other (Post ↔ Tag, User ↔ Role)many_to_manyOn whichever side reads it more — usually both
Loose freeform list (5–10 tags users type by hand, not managed centrally)string_arrayA column on the parent (no join table)

Quick check

You're modelling a school app. A Student has one HomeRoom; a Student takes many Classes; each Class has many Students. How do you wire it?

Try it

Try all three cardinalities on your machine. Each part is self-contained — feel free to do them one at a time.

Part A — one-to-many (Group → Contacts)

Terminal
$grit generate resource Group \
$ --fields "name:string:unique,description:text:optional"
$grit generate resource Contact \
$ --fields "name:string,email:string:unique,phone:string:optional,group:belongs_to:Group"
$grit migrate

In the admin, create one Group then two Contacts pointing at it. Hit GET /api/contacts?group_id=<the-group-uuid> — both contacts come back, each with the embedded group object.

Part B — one-to-one (User → Profile)

Drop this YAML in profile.yaml at the project root and generate from it:

profile.yaml
name: Profile
fields:
- name: user
type: belongs_to
related_model: User
required: true
unique: true
- name: bio
type: text
- name: avatar
type: string
Terminal
$grit generate resource Profile --from profile.yaml
$grit migrate

Create one Profile for your admin user. Then try to create a second Profile for the same user — the API should return a 500 with a unique-constraint error.

Part C — many-to-many (Post ↔ Tag)

Terminal
$grit generate resource Tag \
$ --fields "name:string:unique,slug:slug:name,color:string:optional"
$grit generate resource Post \
$ --fields "title:string,slug:slug,body:richtext,tags:many_to_many:Tag"
$grit migrate

In the admin, create 3 Tags then a Post with all three attached via the multi-select. Hit GET /api/posts/<id> — the tags field comes back as an array of UUIDs (or full Tag objects if you tweak the service to preload).

Paste the three API responses into notes.md.

What's next

You've seen the inline form for every field type Grit supports. The next lesson is about choosing between the inline --fields string and the long-form YAML file — when each shines, and what extra knobs YAML gives you (defaults, the single source of truth you can re-run, anything beyond five fields).

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub