Introduction
Recently at work, I’ve often run into the problem of relational queries in MongoDB. If they are not handled properly, they can easily turn into an N+1 problem. An example scenario would be: “A user can have multiple orders, and multiple orders can correspond to multiple products.”
I also happen to be building a Query Builder based on the Mongo Go Driver: good, so I’ll use this common issue as an opportunity to learn the trade-offs among different approaches.
type User struct { ID bson.ObjectID `bson:"_id"` Name string `bson:"name"` Email string `bson:"email"`}
type Order struct { ID bson.ObjectID `bson:"_id"` UserID bson.ObjectID `bson:"userId"` Status string `bson:"status"` CreatedAt time.Time `bson:"createdAt"`
User *User `bson:"-"` Items []Item `bson:"-"`}
type Item struct { ID bson.ObjectID `bson:"_id"` OrderID bson.ObjectID `bson:"orderId"` Name string `bson:"name"` Price int `bson:"price"` CreatedAt time.Time `bson:"createdAt"`}How to Handle Relational Queries
N+1 Loop
Query the primary data first, then loop through each row to query the related data: Database query performance issue: N+1 problem
var orders []Ordercursor, _ := orderColl.Find(ctx, bson.M{"status": "paid"})cursor.All(ctx, &orders)
for i := range orders { var user User userColl.FindOne(ctx, bson.M{"_id": orders[i].it'sDecode(&user) orders[i].User = &user}One query for the primary data plus N related queries: it’s the classic N+1 problem. 100 orders means 101 round trips. The tricky part is that this code reads very naturally, and with three or five records in local testing everything works perfectly. Usually, you only notice the problem once production data grows.
Embedding
MongoDB is a document database, and the officially recommended approach is “data that’s accessed together should be stored together”, which means embedding related data directly in the same document:
{ "_id": "order_1", "items": [ { "productId": "p1", "name": "Keyboard", "price": 100, "qty": 2 } ], "createdAt": "2026-08-17T00:00:00Z"}This is a common characteristic of document databases: they encourage “modeling data based on access patterns.” But embedding also comes with corresponding costs:
- Document size limit: A single document is limited to 16MB. An embedded relationship that can grow without bounds will eventually break.
- Tied to a single read path: The embedded shape is optimized for one particular read pattern. Once requirements change, the document has to be reshaped.
- Data duplication:
user.nameexists both in the users collection and in every order. If the user changes their name, you have to go back and update all orders, or the data becomes inconsistent. - Not suitable for N:M: Products are referenced by many orders. Embedding is equivalent to copying the same data again into every order.
So while embedding is the most straightforward approach, it is really suitable for data that is bounded, read together, and unlikely to change later, such as a product snapshot at the moment an order is placed. In other cases, keeping references is safer.
$lookup (Server-side Join)
Hand the join over to the database and use an aggregation pipeline to assemble the result in one go:
pipeline := mongo.Pipeline{ bson.D{{Key: "$match", Value: bson.M{"status": "paid"}}}, bson.D{{Key: "$lookup", Value: bson.M{ "from": "users", "localField": "userId", "foreignField": "_id", "as": "user", }}}, bson.D{{Key: "$unwind", Value: bson.M{ "path": "$user", "preserveNullAndEmptyArrays": true, }}},}cursor, _ := orderColl.Aggregate(ctx, pipeline)Getting the assembled result in one query looks ideal, but there are trade-offs to keep in mind:
- Each stage has a 100MB memory limit: Once the data volume grows, you need to enable
allowDiskUse; otherwise, the entire pipeline fails. - Multi-level joins are hard to maintain: After joining two or three levels, the pipeline expands into a large chunk of BSON that is difficult to read.
- Missing an index means a full collection scan: If
foreignFieldhas no index, each primary document scans the foreign collection once. - A bare
$unwindis an inner join:preserveNullAndEmptyArraysdefaults tofalse, so primary documents whose related data cannot be found are dropped entirely, without an error. asis always an array: Even for N:1, it is still an array. To match a Go struct, you need to add$unwindto flatten it.
When you need to aggregate, filter, or sort by related fields on the DB side, $lookup is usually the most direct solution. But if all you want is to fill in related fields, the cost can be harder to control than expected: performance depends on whether the foreign collection hits an index, the entire pipeline can fail when memory gets tight, and from the application layer it is not easy to see which stage is slow. For the simple need of filling fields, doing it in the application layer is often more controllable.
Manual Two-step Query (Application-side Join)
Move the relational operation back into the application layer: query the primary data first, collect all foreign keys, use a single $in to batch-query the related data, and finally assemble everything in memory.
// 1. Query the primary datavar orders []Ordercursor, _ := orderColl.Find(ctx, bson.M{"status": "paid"})cursor.All(ctx, &orders)
// 2. Collect and deduplicate foreign keysuserIDs := make([]bson.ObjectID, 0, len(orders))seen := map[bson.ObjectID]struct{}{}for _, o := range orders { if _, ok := seen[o.UserID]; !ok { seen[o.UserID] = struct{}{} userIDs = append(userIDs, o.UserID) }}
// 3. Fetch everything with one $invar users []Useruc, _ := userColl.Find(ctx, bson.M{"_id": bson.M{"$in": userIDs}})uc.All(ctx, &users)
// 4. Build a map and fill backuserByID := make(map[bson.ObjectID]*User, len(users))for i := range users { userByID[users[i].ID] = &users[i]}for i := range orders { orders[i].User = userByID[orders[i].UserID]}No matter how many orders there are, this is always 2 queries. The related query uses a field such as _id, which is guaranteed to have an index, so the cost is easy to predict and the concept is simple. The downside is that there is too much boilerplate. The section above has to be copied once for every relationship, and copied again for every type. Every time it is the same process: collect foreign keys, deduplicate, $in, build a map, and fill back.
Eager Loading
The logic above can actually be abstracted into a reusable feature. This is the Eager Loading provided by many ORMs:
// Laravel EloquentOrder::with('user')->get();// GORMdb.Preload("User").Find(&orders)// Prismaprisma.order.findMany({ include: { user: true } })One line declares which relationships to load, and underneath it automatically expands into the same batch-query process described above. The author only needs to describe the relationships and does not have to hand-write collection, batching, and assembly every time.
The Mongo Go Driver intentionally stays low-level, and the official driver does not provide this layer of abstraction. So the next step is to implement more convenient relational queries on top of the native driver.
Summary
| Approach | Query count | Suitable scenario | Main cost |
|---|---|---|---|
| N+1 Loop | 1 + N | Should not be used | O(N) latency |
| Embedding | 1 | Read together, rarely changes, bounded | Data duplication, 16MB limit, tied to query direction |
$lookup | 1 | Need to aggregate/sort related fields on the DB side | Index-sensitive, memory limit, pipeline hard to maintain |
| Manual two-step query | 1 + M (M = number of relationships) | General read paths | Lots of boilerplate, easy to get wrong |
| Eager Loading | 1 + M | Same as above, but reusable | Requires building the abstraction first |
Simplifying Relational Queries
The middle is simply a query with $in. What is really missing is only the beginning and the end: extracting the foreign keys, and building an index from the returned data. Neither of these requires touching the database or knowing any relationship declarations, so I wrote them as three generic functions.
Collecting Deduplicated Foreign Keys
func Keys[T any, K comparable](rows []T, key func(T) K) []K { var zero K seen := make(map[K]struct{}, len(rows)) out := make([]K, 0, len(rows)) for _, row := range rows { k := key(row) if k == zero { continue } if _, dup := seen[k]; dup { continue } seen[k] = struct{}{} out = append(out, k) } return out}ids := good.Keys(orders, func(o Order) bson.ObjectID { return o.UserID })if len(ids) == 0 { return orders, nil}The implementation is simple, but there are two details:
- Skip zero values directly: An empty string, a zero-value
bson.ObjectID, or0means “there is no such reference,” not “go query the zero value.” If you really send it into$in, it may instead match a document that happens to store a zero value. - A return length of 0 does not mean an error: The return value will not be
nil; a length of 0 only means there is nothing to query. Whether to return early because of that is left to the caller. An empty array in$inis a valid query, and the result is also empty, so skipping it saves one round trip rather than fixing correctness.
Querying the Other Side Once with in
This middle step does not require anything new. It is just a normal query:
users, err := userColl.Where("_id", "in", ids).Select("_id", "name").All(ctx)This step is intentionally not wrapped. Because it is just a normal query, you can keep adding conditions and filters, and you can print out the filter for inspection before sending it:
q := good.NewQuery[User]().Where("_id", "in", ids)fmt.Println(q.Filter()) // map[_id:map[$in:[...]]]Building an Index from the Returned Results and Filling Back
After the two queries, you have two piles of data that do not know about each other:
orders := []Order{ {ID: o1, UserID: u1}, // Only userId, no name {ID: o2, UserID: u2},}users := []User{ {ID: u2, Name: "Ming"}, // Only the name, does not know who references it {ID: u1, Name: "Mei"},}But what you want to output is the combined result, such as orders[0].User.Name. SQL JOIN, $lookup, and ORM with() all have someone else merge things for you before handing them back. If you query twice yourself, you have to merge them yourself. This step is the fill-back.
You can also merge with two nested loops, but every primary record has to scan the entire stack of related data. 100 × 100 becomes ten thousand comparisons:
for i := range orders { for _, u := range users { if u.ID == orders[i].UserID { orders[i].User = &u; break } }}If you first arrange the related data into a map and then do lookups, it changes from O(N×M) to O(N+M). So what you need is very simple: a map that can fetch values directly by foreign key. The only difference is the direction of the relationship:
func By[T any, K comparable](rows []T, key func(T) K) map[K]Tfunc ByAll[T any, K comparable](rows []T, key func(T) K) map[K][]Tfunc By[T any, K comparable](rows []T, key func(T) K) map[K]T { out := make(map[K]T, len(rows)) for _, row := range rows { out[key(row)] = row } return out}func ByAll[T any, K comparable](rows []T, key func(T) K) map[K][]T { out := make(map[K][]T, len(rows)) for _, row := range rows { k := key(row) out[k] = append(out[k], row) } return out}The only difference between the two is what happens when the same key collides. Try running the same input through them:
items := []Item{ {OrderID: o1, Name: "Keyboard"}, {OrderID: o2, Name: "Mouse"}, {OrderID: o1, Name: "Monitor"}, // ← key o1 appears a second time}good.By(items, ...) // map[o1:{Monitor} o2:{Mouse}] ← Keyboard was overwrittengood.ByAll(items, ...) // map[o1:[{Keyboard} {Monitor}] o2:[{Mouse}]]By: One key maps to one document. Use it for belongs-to and one-to-one. If the same key appears twice, the latter overwrites the former; after all, a map can only hold one value. In this case, you should switch toByAll. Note that when a map lookup misses, it returns the zero value rather than an error, so you should check withv, ok := m[k]before filling back. Otherwise, when the relationship does not exist, an empty struct will be silently inserted.ByAll: One key maps to multiple documents. Use it for has-many, where the foreign key lives on the child document. The order within each group preserves the order in which documents are returned, so theSortused in the query is the order inside each group.
Putting It Together
For belongs-to, attach the user who placed the order to each order:
orders, err := orderColl.Where("status", "=", "paid").Sort("-createdAt").All(ctx)
ids := good.Keys(orders, func(o Order) bson.ObjectID { return o.UserID })users, err := userColl.Where("_id", "in", ids).Select("_id", "name").All(ctx)
byID := good.By(users, func(u User) bson.ObjectID { return u.ID })for i := range orders { if u, ok := byID[orders[i].UserID]; ok { orders[i].User = &u }}For has-many, just switch to ByAll, and the foreign key moves from the primary document to the child document:
items, err := itemColl.Where("orderId", "in", good.Keys(orders, func(o Order) bson.ObjectID { return o.ID }),).Sort("-createdAt").All(ctx)
byOrder := good.ByAll(items, func(i Item) bson.ObjectID { return i.OrderID })for i := range orders { orders[i].Items = byOrder[orders[i].ID]}Conclusion
Looking back after writing this, the SQL habit is usually “normalize first, then figure out how to JOIN.” MongoDB is more like “design the data model according to access patterns first, then go back and handle references only where they are truly needed.”
The trade-off in good is to avoid abstraction as much as possible. Nowhere in the entire package does it “know” that an order has a user, so there is no relationship to register and no lazy load that will secretly issue queries for you. The cost is that each relationship requires three extra lines, but at least there is no query that happens without you noticing. In use, it stays closer to the Native Go Driver.