Skip to content
gohacks.dev
Databases

Ent Many to Many Edges With Edge Schemas and Extra Fields

Model a many to many in Ent with an edge schema that carries a grade and a date, query through the link, and see the composite key stop duplicate enrolments

10 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

Many to many relationships are the first place where a tidy ORM model meets a messy requirement. A student takes courses and a course has students, and the join table needs nothing but two ids until someone asks for the grade, the enrolment date, who approved it, or the order of the items on an invoice. At that point the join table is not a join table any more, it is an entity, and an ORM that hides it has to be told otherwise. Ent calls the answer an edge schema. This article builds one on PostgreSQL 18.6 and runs the operations that make it worth having.

Tested with Go 1.25.6, Ent v0.14.5, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

It extends the edge section of the Ent tutorial. The GORM equivalent is the custom join model in GORM associations, where the same trade is made with a struct and SetupJoinTable. The query side is in Ent queries.

The plain version, and when it stops being enough

Without extra data, a many to many in Ent is two edges that point at each other:

Go
// Student
edge.To("courses", Course.Type)
// Course
edge.From("students", Student.Type).Ref("courses")

Ent creates a join table with two foreign key columns and a composite primary key, and gives you student.AddCourses(c), QueryCourses() and the predicates HasCoursesWith. For tags on posts, friends in a social graph or roles on users, that is all you need.

It stops when the link carries data. A plain edge has no column to put a grade in and no entity to query. You can bolt on a separate table with its own ids, but then the many to many is two one to many relations that you keep consistent by hand.

The edge schema

An edge schema is an ordinary entity that Ent uses as the join table. Three pieces make it work.

The two sides declare the edge with Through, naming the edge schema type:

ent/schema/student.goGo
package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
)

type Student struct{ ent.Schema }

func (Student) Fields() []ent.Field { return []ent.Field{field.String("name").Unique()} }

func (Student) Edges() []ent.Edge {
	return []ent.Edge{edge.To("courses", Course.Type).Through("enrollments", Enrollment.Type)}
}
ent/schema/course.goGo
package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
)

type Course struct{ ent.Schema }

func (Course) Fields() []ent.Field { return []ent.Field{field.String("title").Unique()} }

func (Course) Edges() []ent.Edge {
	return []ent.Edge{edge.From("students", Student.Type).Ref("courses").Through("enrollments", Enrollment.Type)}
}

The edge schema holds the two foreign keys as real fields, any extra fields, edges back to both sides that use those fields, and a composite id annotation:

ent/schema/enrollment.goGo
package schema

import (
	"time"

	"entgo.io/ent"
	"entgo.io/ent/schema"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
)

// Enrollment is the join table, and it carries data of its own.
type Enrollment struct{ ent.Schema }

func (Enrollment) Annotations() []schema.Annotation {
	return []schema.Annotation{field.ID("student_id", "course_id")}
}

func (Enrollment) Fields() []ent.Field {
	return []ent.Field{
		field.Int("student_id"),
		field.Int("course_id"),
		field.String("grade").Optional(),
		field.Time("enrolled_at").Default(time.Now),
	}
}

func (Enrollment) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("student", Student.Type).Unique().Required().Field("student_id"),
		edge.To("course", Course.Type).Unique().Required().Field("course_id"),
	}
}

The pieces that matter:

  • field.Int("student_id") and field.Int("course_id") are fields, not hidden columns, so you can filter on them directly (enrollment.StudentID(1)).
  • edge.To("student", Student.Type).Unique().Required().Field("student_id") links the entity to a side through that field. Each enrolment has exactly one student and one course.
  • field.ID("student_id", "course_id") makes the pair the primary key. There is no separate id column, and the pair cannot repeat.
  • grade is optional, and enrolled_at defaults to now. These are the data that justify the entity.

The program

main.goGo
package main

import (
	"context"
	"fmt"

	"example.test/lab/ent"
	"example.test/lab/ent/course"
	"example.test/lab/ent/enrollment"
	"example.test/lab/ent/student"
)

func main() {
	ctx := context.Background()
	client := open("l0744")
	defer client.Close()
	must(client.Schema.Create(ctx))

	ada := client.Student.Create().SetName("Ada").SaveX(ctx)
	bob := client.Student.Create().SetName("Bob").SaveX(ctx)
	db := client.Course.Create().SetTitle("Databases").SaveX(ctx)
	gc := client.Course.Create().SetTitle("Go Concurrency").SaveX(ctx)

	// The join row is an entity: create it directly to set its own fields.
	client.Enrollment.Create().SetStudent(ada).SetCourse(db).SetGrade("A").SaveX(ctx)
	client.Enrollment.Create().SetStudent(ada).SetCourse(gc).SaveX(ctx)
	client.Enrollment.Create().SetStudent(bob).SetCourse(db).SetGrade("B").SaveX(ctx)

	// The plain many to many edge still works for traversal.
	titles := ada.QueryCourses().Order(ent.Asc(course.FieldTitle)).Select(course.FieldTitle).StringsX(ctx)
	fmt.Println("Ada's courses:", titles)

	// The join rows carry the extra fields.
	for _, e := range ada.QueryEnrollments().Order(ent.Asc(enrollment.FieldCourseID)).AllX(ctx) {
		fmt.Printf("enrollment course=%d grade=%q has enrolled_at=%v\n", e.CourseID, e.Grade, !e.EnrolledAt.IsZero())
	}

	// Filter through the extra field.
	graded := client.Student.Query().Where(student.HasEnrollmentsWith(enrollment.GradeEQ("A"))).Select(student.FieldName).StringsX(ctx)
	fmt.Println("students with an A:", graded)

	// Load the join rows and the courses in one eager load.
	s := client.Student.Query().Where(student.Name("Ada")).WithEnrollments(func(q *ent.EnrollmentQuery) { q.WithCourse() }).OnlyX(ctx)
	for _, e := range s.Edges.Enrollments {
		fmt.Println("eager:", e.Edges.Course.Title)
	}

	// The composite primary key makes a duplicate link impossible.
	_, err := client.Enrollment.Create().SetStudent(ada).SetCourse(db).Save(ctx)
	fmt.Println("duplicate enrollment is a constraint error:", ent.IsConstraintError(err))

	// Update the data on the link.
	client.Enrollment.Update().Where(enrollment.StudentID(ada.ID), enrollment.CourseID(gc.ID)).SetGrade("B+").ExecX(ctx)
	fmt.Println("updated grade:", client.Enrollment.Query().Where(enrollment.StudentID(ada.ID), enrollment.CourseID(gc.ID)).OnlyX(ctx).Grade)

	// Removing a student does not remove their join rows by itself.
	err = client.Student.DeleteOne(bob).Exec(ctx)
	fmt.Println("delete a student who has enrollments:", ent.IsConstraintError(err))
}
db.goGo
package main

import (
	"database/sql"
	"log"

	"entgo.io/ent/dialect"
	entsql "entgo.io/ent/dialect/sql"
	"example.test/lab/ent"
	_ "github.com/jackc/pgx/v5/stdlib"
)

const base = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"

// open recreates a schema and returns a client whose connections use it.
func open(schema string, opts ...ent.Option) *ent.Client {
	admin, err := sql.Open("pgx", base)
	if err != nil {
		log.Fatal(err)
	}
	admin.Exec("drop schema if exists " + schema + " cascade")
	admin.Exec("create schema " + schema)
	admin.Close()
	db, err := sql.Open("pgx", base+"&search_path="+schema)
	if err != nil {
		log.Fatal(err)
	}
	drv := entsql.OpenDB(dialect.Postgres, db)
	return ent.NewClient(append([]ent.Option{ent.Driver(drv)}, opts...)...)
}

func must(err error) {
	if err != nil {
		log.SetFlags(0)
		log.Fatal(err)
	}
}
ent/generate.goGo
package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate  ./schema
Shell
set -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go run .
Text
Ada's courses: [Databases Go Concurrency]
enrollment course=1 grade="A" has enrolled_at=true
enrollment course=2 grade="" has enrolled_at=true
students with an A: [Ada]
eager: Databases
eager: Go Concurrency
duplicate enrollment is a constraint error: true
updated grade: B+
delete a student who has enrollments: true

Because the link is an entity, you create it directly, and the builder has typed setters for its own fields:

client.Enrollment.Create().SetStudent(ada).SetCourse(db).SetGrade("A").SaveX(ctx)

Two students and two courses produce three enrolments: Ada in both courses (an A in Databases and no grade yet in Go Concurrency) and Bob in Databases with a B. The enrolled_at timestamp was filled by its default.

Ent also generates ada.AddCourses(...) on the plain edge, which creates links with default values for the extra fields. Use the entity form whenever you care about the extra data.

There are three useful ways in, and the lab shows each.

Through the plain edge. ada.QueryCourses() still works. It returned [Databases Go Concurrency] when ordered by title. This is the simple traversal, which joins through the table without exposing it.

Through the link entity. ada.QueryEnrollments() returns the join rows. The output shows enrollment course=1 grade="A" has enrolled_at=true and enrollment course=2 grade="" has enrolled_at=true. This is how you read the extra data.

Eager loading both. client.Student.Query().WithEnrollments(func(q *ent.EnrollmentQuery) { q.WithCourse() }) loaded the student, their enrolments and each enrolment's course in three statements, and the lab printed the course titles from e.Edges.Course.Title. It is the right shape for a page that lists a student's courses with grades.

Filtering by the link's field. student.HasEnrollmentsWith(enrollment.GradeEQ("A")) found [Ada]: students with at least one enrolment graded A. With a bare join table that question cannot be asked in the typed API. It is a better model for exactly this reason: queries on the relation's attributes are first class.

Updating and constraints

Updating the link. client.Enrollment.Update().Where(enrollment.StudentID(ada.ID), enrollment.CourseID(gc.ID)).SetGrade("B+").ExecX(ctx) changed the grade on one link, and a follow up query printed B+. Updating an edge's data is an update of a normal entity, with a normal Where. There is no "association replace" step to reason about.

No duplicates. Enrolling Ada in Databases a second time failed: duplicate enrollment is a constraint error: true. The composite primary key on the two ids does the job at the database level, so even a racing pair of requests cannot both succeed. Without an edge schema, Ent's default join table also has a composite key, so this protection is the same either way. If you add an id column instead of the composite key, add a unique index over the two foreign keys, or duplicates become legal.

Deleting. client.Student.DeleteOne(bob).Exec(ctx) failed with a constraint error because Bob still had an enrolment. The foreign key blocks the delete. There are three ways to handle it, and the choice is a product decision:

  1. Delete the links first, in a transaction (Ent transactions).
  2. Declare cascade on the edge with an annotation (entsql.OnDelete(entsql.Cascade)), so the database removes the links with the student. Convenient, and risky if a student's grade history must be kept.
  3. Keep both: soft delete the student and leave the links, as covered in mixins.

Compared with GORM

GORM's many2many tag creates the same table, and a custom join model needs SetupJoinTable, an explicit struct and manual queries. In Ent the extra fields are part of the schema, typed predicates cover the link, and eager loading nests. The trade is the generated code and the more elaborate schema. For a model with only a handful of many to many relationships, either is fine. For a model where links have attributes everywhere (memberships with roles, line items with quantities, follows with a date), the edge schema is the point at which Ent's approach pays for itself.

Design notes

  • Name the fields explicitly. student_id and course_id are readable, appear in the migration and match what other tools expect.
  • Index the second column. The composite key serves lookups by student_id. Lookups by course_id alone need their own index on a large table.
  • Do not overload the link. If it grows its own lifecycle and relations (an enrolment with payments and attendance), it is a full entity. Give it its own id and edges.
  • Keep timestamps in the link. "When did this start" is the most common attribute and the one hardest to reconstruct later.

Checklist

  • Use a plain edge until the link needs data, then switch to Through with an edge schema.
  • Declare both foreign keys as fields and use a composite id.
  • Create and update links as entities, and query them with typed predicates.
  • Decide the delete behaviour for the foreign keys deliberately.
  • Add an index for the second key if you query from that side.

The feature is documented in the Ent edge schema guide and in the many to many examples.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.