Building Relational Queries in Go MongoDB

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.”

USERSObjectId_idPKstringnamestringemailORDERSObjectId_idPKObjectIduserIdFKarrayitemsdatecreatedAtPRODUCTSObjectId_idPKstringnamenumberpriceplacesitems references

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 []Order
cursor, _ := 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",
"user": { "_id": "user_1", "name": "Alice", "email": "[email protected]" },
"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.name exists 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 foreignField has no index, each primary document scans the foreign collection once.
  • A bare $unwind is an inner join: preserveNullAndEmptyArrays defaults to false🔗, so primary documents whose related data cannot be found are dropped entirely, without an error.
  • as is always an array🔗: Even for N:1, it is still an array. To match a Go struct, you need to add $unwind to 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 data
var orders []Order
cursor, _ := orderColl.Find(ctx, bson.M{"status": "paid"})
cursor.All(ctx, &orders)
// 2. Collect and deduplicate foreign keys
userIDs := 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 $in
var users []User
uc, _ := userColl.Find(ctx, bson.M{"_id": bson.M{"$in": userIDs}})
uc.All(ctx, &users)
// 4. Build a map and fill back
userByID := 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 Eloquent
Order::with('user')->get();
// GORM
db.Preload("User").Find(&orders)
// Prisma
prisma.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

ApproachQuery countSuitable scenarioMain cost
N+1 Loop1 + NShould not be usedO(N) latency
Embedding1Read together, rarely changes, boundedData duplication, 16MB limit, tied to query direction
$lookup1Need to aggregate/sort related fields on the DB sideIndex-sensitive, memory limit, pipeline hard to maintain
Manual two-step query1 + M (M = number of relationships)General read pathsLots of boilerplate, easy to get wrong
Eager Loading1 + MSame as above, but reusableRequires building the abstraction first

Simplifying Relational Queries

Query primary data
orders

Collect and deduplicate foreign keys
Keys

Query the other side once with in
Where(_id, in, ids)

Build index
By / ByAll

Fill fields back

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, or 0 means “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 $in is 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]T
func ByAll[T any, K comparable](rows []T, key func(T) K) map[K][]T
func 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 overwritten
good.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 to ByAll. Note that when a map lookup misses, it returns the zero value rather than an error, so you should check with v, 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 the Sort used 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.