Skip to content
gohacks.dev
Databases

MongoDB in Go With the Official Driver Explained Simply

Use MongoDB from Go with the official v2 driver: structs with bson tags, filters, update operators, aggregation, indexes and explain output, all measured

14 min read
Summarize in AI
Flat illustration of a database beside the table it holds, with the MongoDB logo.

MongoDB stores documents, JSON like records with nested fields and arrays, and queries them by example. For a Go developer coming from SQL the surprises are not in the API, which is small, but in how little the database enforces for you: no schema unless you ask for one, no joins in the usual sense, and indexes that you must design as carefully as in any other store. This guide covers the official driver, version 2, from connecting to aggregation, with a lab on Go 1.25.6, driver v2.3.0 and MongoDB 8.3 that prints real results for each step.

Tested with Go 1.25.6, mongo-driver v2.3.0, MongoDB 8.3.13 (mongo:8) in Docker, macOS on Apple Silicon (arm64)

It belongs to the database cornerstone, which starts from SQL; the comparison of stores for a new project is in choosing a database. Other non relational stores in the cluster include Redis and DynamoDB.

Running the lab

The lab uses a throwaway container:

Shell
docker run -d --name gh-lab-mongo -p 127.0.0.1:57017:27017 mongo:8
main.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"time"

	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

type Book struct {
	ID     bson.ObjectID `bson:"_id,omitempty"`
	ISBN   string        `bson:"isbn"`
	Title  string        `bson:"title"`
	Genre  string        `bson:"genre"`
	Year   int           `bson:"year"`
	Stock  int           `bson:"stock"`
	Author struct {
		Name string `bson:"name"`
	} `bson:"author"`
}

type explainOut struct {
	QueryPlanner struct {
		WinningPlan struct {
			Stage      string `bson:"stage"`
			InputStage struct {
				Stage string `bson:"stage"`
			} `bson:"inputStage"`
		} `bson:"winningPlan"`
	} `bson:"queryPlanner"`
	ExecutionStats struct {
		TotalDocsExamined int64 `bson:"totalDocsExamined"`
		NReturned         int64 `bson:"nReturned"`
	} `bson:"executionStats"`
}

func stage(ctx context.Context, db *mongo.Database, filter bson.D) string {
	var out explainOut
	cmd := bson.D{{Key: "explain", Value: bson.D{{Key: "find", Value: "books636"}, {Key: "filter", Value: filter}}}, {Key: "verbosity", Value: "executionStats"}}
	if err := db.RunCommand(ctx, cmd).Decode(&out); err != nil {
		log.Fatal(err)
	}
	st := out.QueryPlanner.WinningPlan.Stage
	if in := out.QueryPlanner.WinningPlan.InputStage.Stage; in != "" {
		st += " > " + in
	}
	return fmt.Sprintf("%s (examined %d docs, returned %d)", st, out.ExecutionStats.TotalDocsExamined, out.ExecutionStats.NReturned)
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	client, err := mongo.Connect(options.Client().ApplyURI("mongodb://127.0.0.1:57017").SetTimeout(5 * time.Second))
	if err != nil {
		log.Fatal(err)
	}
	defer client.Disconnect(ctx)
	if err := client.Ping(ctx, nil); err != nil {
		log.Fatal(err)
	}
	db := client.Database("lab")
	books := db.Collection("books636")
	_ = books.Drop(ctx)

	// 1. Insert one, then many, with a struct.
	a := Book{ISBN: "978-0", Title: "The Go Programming Language", Genre: "tech", Year: 2015, Stock: 4}
	a.Author.Name = "Donovan"
	res, err := books.InsertOne(ctx, a)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("1. InsertOne returned an ObjectID: %v (24 hex characters: %v)\n", res.InsertedID != nil, len(res.InsertedID.(bson.ObjectID).Hex()) == 24)
	var many []any
	genres := []string{"tech", "fiction", "history", "science"}
	for i := 0; i < 20000; i++ {
		many = append(many, Book{ISBN: fmt.Sprintf("isbn-%05d", i), Title: fmt.Sprintf("book %d", i), Genre: genres[i%4], Year: 1950 + i%75, Stock: i % 10})
	}
	many_, err := books.InsertMany(ctx, many)
	if err != nil {
		log.Fatal(err)
	}
	n, _ := books.CountDocuments(ctx, bson.D{})
	fmt.Println("   InsertMany inserted", len(many_.InsertedIDs), "| documents in the collection:", n)

	// 2. A query on a field without an index, then with one.
	filter := bson.D{{Key: "isbn", Value: "isbn-15000"}}
	fmt.Println("2. find by isbn, no index:", stage(ctx, db, filter))
	if _, err := books.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: bson.D{{Key: "isbn", Value: 1}}, Options: options.Index().SetUnique(true)}); err != nil {
		log.Fatal(err)
	}
	fmt.Println("   find by isbn, unique index:", stage(ctx, db, filter))

	// 3. Errors worth telling apart.
	_, err = books.InsertOne(ctx, Book{ISBN: "isbn-00001", Title: "duplicate"})
	fmt.Println("3. duplicate isbn: IsDuplicateKeyError =", mongo.IsDuplicateKeyError(err))
	err = books.FindOne(ctx, bson.D{{Key: "isbn", Value: "nope"}}).Err()
	fmt.Println("   missing document: ErrNoDocuments =", errors.Is(err, mongo.ErrNoDocuments))

	// 4. Update operators and the counts they report.
	up, _ := books.UpdateMany(ctx, bson.D{{Key: "genre", Value: "tech"}}, bson.D{{Key: "$inc", Value: bson.D{{Key: "stock", Value: 1}}}})
	fmt.Printf("4. UpdateMany $inc on tech: matched %d, modified %d\n", up.MatchedCount, up.ModifiedCount)
	up, _ = books.UpdateOne(ctx, bson.D{{Key: "isbn", Value: "new-isbn"}}, bson.D{{Key: "$set", Value: bson.D{{Key: "title", Value: "created by upsert"}}}}, options.UpdateOne().SetUpsert(true))
	fmt.Printf("   upsert of a missing isbn: matched %d, modified %d, upserted id set: %v\n", up.MatchedCount, up.ModifiedCount, up.UpsertedID != nil)
	var after Book
	err = books.FindOneAndUpdate(ctx, bson.D{{Key: "isbn", Value: "978-0"}}, bson.D{{Key: "$inc", Value: bson.D{{Key: "stock", Value: -1}}}}, options.FindOneAndUpdate().SetReturnDocument(options.After)).Decode(&after)
	fmt.Println("   FindOneAndUpdate returned the document after the change, stock:", after.Stock, "err:", err)

	// 5. An aggregation pipeline.
	cur, err := books.Aggregate(ctx, mongo.Pipeline{
		{{Key: "$match", Value: bson.D{{Key: "year", Value: bson.D{{Key: "$gte", Value: 2000}}}}}},
		{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$genre"}, {Key: "books", Value: bson.D{{Key: "$sum", Value: 1}}}, {Key: "stock", Value: bson.D{{Key: "$sum", Value: "$stock"}}}}}},
		{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
	})
	if err != nil {
		log.Fatal(err)
	}
	var groups []bson.M
	if err := cur.All(ctx, &groups); err != nil {
		log.Fatal(err)
	}
	fmt.Println("5. aggregate, books published from 2000 grouped by genre:")
	for _, g := range groups {
		fmt.Printf("   %-8v books=%v stock=%v\n", g["_id"], g["books"], g["stock"])
	}

	// 6. Time limits.
	short, c2 := context.WithTimeout(ctx, 50*time.Millisecond)
	defer c2()
	_, err = db.Collection("books636").Aggregate(short, mongo.Pipeline{{{Key: "$project", Value: bson.D{{Key: "slow", Value: bson.D{{Key: "$function", Value: bson.D{{Key: "body", Value: "function() { sleep(200); return 1 }"}, {Key: "args", Value: bson.A{}}, {Key: "lang", Value: "js"}}}}}}}}})
	fmt.Println("6. a 50ms context on a slow aggregation:", errors.Is(err, context.DeadlineExceeded))
}
Text
1. InsertOne returned an ObjectID: true (24 hex characters: true)
   InsertMany inserted 20000 | documents in the collection: 20001
2. find by isbn, no index: COLLSCAN (examined 20001 docs, returned 1)
   find by isbn, unique index: EXPRESS_IXSCAN (examined 1 docs, returned 1)
3. duplicate isbn: IsDuplicateKeyError = true
   missing document: ErrNoDocuments = true
4. UpdateMany $inc on tech: matched 5001, modified 5001
   upsert of a missing isbn: matched 0, modified 0, upserted id set: true
   FindOneAndUpdate returned the document after the change, stock: 4 err: <nil>
5. aggregate, books published from 2000 grouped by genre:
   fiction  books=1663 stock=8317
   history  books=1663 stock=6650
   science  books=1662 stock=8308
   tech     books=1663 stock=8316
6. a 50ms context on a slow aggregation: true

A note on versions: this is the v2 module, go.mongodb.org/mongo-driver/v2. Its API differs from the v1 you will find in older tutorials. mongo.Connect no longer takes a context, bson.ObjectID replaced primitive.ObjectID, and the BSON packages moved under the same module path. Import paths with /v2 are what you want.

Connecting

mongo.Connect(options.Client().ApplyURI(uri)) returns a *mongo.Client that manages a connection pool and is safe for concurrent use. Create one for the life of the program, ping it at start-up (client.Ping(ctx, nil)) because Connect returns before it has reached a server, and call Disconnect on shutdown. SetTimeout sets a default operation timeout for the client, the guard against calls with no deadline. A *mongo.Database and a *mongo.Collection are cheap handles over the client.

Documents as structs

The Book struct maps to a document through bson tags: bson:"isbn" names the field, and bson:"_id,omitempty" marks the id so that an empty bson.ObjectID is left out and the server generates one. Nested structs become nested documents, as Author does. InsertOne returned the generated id, a 12 byte ObjectID whose hex form is 24 characters, and InsertMany inserted 20,000 documents from a slice of any, which is the driver's signature for "a list of anything that marshals".

Every document has an _id and it is always indexed and unique. Using your own natural key as _id saves an index, and the generated ObjectID is time ordered, which keeps inserts append like.

Filters, updates and the types they use

MongoDB queries are documents, and the driver gives you bson.D for them. It is an ordered list of key and value pairs, written bson.D{{Key: "genre", Value: "tech"}}. Order matters for update documents and for index keys, which is why a slice is used rather than a map. bson.M is the unordered map form, convenient for decoding results where order does not matter, and bson.A is an array.

Operators are keys that start with a dollar sign: $gte, $in, $set, $inc. The lab uses four patterns:

  • UpdateMany with $inc raised the stock of every tech book by one and reported matched 5001, modified 5001. MatchedCount is how many documents the filter found and ModifiedCount how many actually changed; they differ when an update sets a field to the value it already has.
  • An upsert, UpdateOne(..., options.UpdateOne().SetUpsert(true)), with a filter that matched nothing, reported matched 0, modified 0 and an UpsertedID: the document was created from the filter and the $set.
  • FindOneAndUpdate with SetReturnDocument(options.After) increments a counter and returns the document as it is afterwards, in one atomic operation. Use it for counters, claims and any "read then change" that must not interleave.
  • A write that does not exist as a single call is the transaction. Multi document transactions in MongoDB need a replica set, and this standalone container is not one, so the lab does not run them; the driver offers client.StartSession and session.WithTransaction, and it is the right tool for changes that must be atomic across documents.

Errors worth telling apart

Three checks cover most handling, and none needs a string comparison:

  • errors.Is(err, mongo.ErrNoDocuments) for FindOne that matched nothing. The lab's lookup of a missing ISBN returned it. Like sql.ErrNoRows, it is an expected outcome.
  • mongo.IsDuplicateKeyError(err) for a violation of a unique index. Inserting a second book with an existing ISBN returned true. This is how you implement "create unless it exists" and turn the failure into a conflict response.
  • mongo.IsTimeout(err) and errors.Is(err, context.DeadlineExceeded) for time limits. The last step of the lab ran a deliberately slow aggregation under a 50 millisecond context and got context.DeadlineExceeded.

Write errors from InsertMany and BulkWrite are collected as a mongo.BulkWriteException that lists each failed operation; look inside it when a batch partly succeeds.

Indexes, and what explain says

A MongoDB query without an index reads every document. The lab asks the server to explain a find on isbn through the explain command with executionStats, before and after creating an index:

  • No index: COLLSCAN (examined 20001 docs, returned 1). The server read the entire collection to return one document.
  • With a unique index on isbn: EXPRESS_IXSCAN (examined 1 docs, returned 1). EXPRESS_IXSCAN is a fast path that MongoDB 8 uses for simple lookups by an indexed field; on older servers the stage is IXSCAN under a FETCH. Either way it examined one document.

The same reading skills as in index basics and reading EXPLAIN apply: look at the stage, at documents examined against returned, and at whether a sort is done in memory. Compound indexes follow the equality, sort, range order of fields, and an index on {genre: 1, year: -1} serves a query that filters by genre and sorts by year. Create indexes at deploy time and not on the first request, and know that building one on a large collection takes time and resources; background builds are the default in current versions.

Aggregation

The aggregation framework is a pipeline of stages, each transforming the stream of documents, and it is MongoDB's answer to group by, joins and reporting. The lab's pipeline has three stages:

  1. $match keeps books published from 2000 on. Put it first so the database can use an index and handle fewer documents.
  2. $group buckets by genre and computes a count and a sum of stock.
  3. $sort orders the groups.

The result, read with cursor.All(ctx, &groups), was one document per genre: fiction 1,663 books with stock 8,317, history 1,663 with 6,650, science 1,662 with 8,308 and tech 1,663 with 8,316. The pipeline ran on the server, and only four documents crossed the network. Other stages cover joins ($lookup), array unwinding ($unwind), reshaping ($project, $addFields) and pagination ($skip, $limit); $lookup is a join that works and costs, and heavy use of it usually means the data should be modelled differently.

Decoding details that bite

BSON and Go do not line up perfectly, and the differences show up as data bugs, not errors.

Zero values. A struct field with no omitempty writes its zero value: Stock int writes stock: 0, and a time.Time zero writes year 1. When you build an update from a struct you can overwrite fields with zeros by accident. For partial updates, build a $set document with only the fields you mean to change, and use pointers (*int) for fields where "absent" and "zero" differ.

Times. BSON stores dates as milliseconds since the epoch in UTC. A time.Time loses its location and its sub millisecond precision on a round trip, so comparing a stored time with the original in a test fails by nanoseconds. Truncate to milliseconds and compare in UTC.

Numbers. BSON has 32 bit and 64 bit integers, doubles and decimals. Decoding into bson.M gives an int32 for a small integer and an int64 for a large one, which breaks code that asserts a single type. Decode into a struct, where the field type decides, and use bson.Decimal128 or store money as integer cents.

Unknown fields. A struct ignores fields it does not declare, which lets old code read new documents and also hides typos in tags. Use bson:",inline" to flatten an embedded struct, and bson:"-" to skip a field.

Cursors and paging

Find returns a *mongo.Cursor, and it holds a server side cursor and a connection until it is closed or exhausted. Treat it like sql.Rows: defer cursor.Close(ctx), loop with cursor.Next(ctx), decode each document with cursor.Decode, and check cursor.Err() after the loop. cursor.All(ctx, &slice) reads everything and closes it, which is convenient and loads the whole result into memory, so use it only for bounded results. A cursor left open past its idle timeout, ten minutes by default, is closed by the server, and the next Next fails.

For pages, SetSkip has the same problem as SQL's offset: the server walks and discards the skipped documents, so deep pages are slow. Page by a key instead: sort by _id (or another unique indexed field), remember the last value, and query {_id: {$gt: last}} with a limit, which is the idea in keyset pagination. Always give a Find a limit or a projection when you display a list; fetching whole large documents to show a title wastes memory and bandwidth.

Validation and change over time

A collection accepts any document unless you attach a validator, and with several services writing to it, one of them will eventually write a field as a string that the others read as a number. Define a $jsonSchema validator when you create the collection, with required fields and types, and set validationAction to error once existing data conforms. For evolving data, store a schemaVersion field in each document, make readers tolerate old versions, and migrate lazily on write or with a batch job. This is the migration problem of database migrations moved into application code, and it needs the same discipline: never assume every document has the newest shape.

Testing

Test against a real MongoDB, as the lab does. A container takes a few seconds to start, and behaviours such as index use, duplicate key errors, upsert counts and aggregation results are the server's, not the driver's. Use a fresh database name per test run and drop it afterwards, create the indexes your code depends on in the test setup, and assert on explain for the queries that must stay indexed, so a refactor that drops an index fails a test rather than a pager. For transactions you need a single node replica set; the official image can be started with --replSet and initialised with rs.initiate().

Modelling: embed or reference

The decision that matters most in MongoDB is not in the driver. Documents let you embed related data, an order with its lines inside, which makes the common read one fetch and the write atomic by default, since a single document update is atomic. It costs duplication and documents that grow. Referencing, storing the id of another document, keeps data normalised and costs a second query or a $lookup. A workable rule: embed when the data is read together and bounded in size (an address, a handful of line items), reference when it is large, unbounded or shared (all comments on a post, every order of a customer). A document has a 16MB limit, and an array that grows forever will reach it.

Practical rules

  1. One Client per process, a ping at start, Disconnect at the end.
  2. Always pass a context with a deadline, and set a client Timeout.
  3. Index every field you filter or sort on, and read explain for each hot query.
  4. Use FindOneAndUpdate and update operators ($inc, $set, $push) for atomic changes, not a read, modify and replace.
  5. Check ErrNoDocuments and IsDuplicateKeyError by type.
  6. Decode into structs for known shapes and bson.M for exploration only.
  7. Add schema validation ($jsonSchema on the collection) when more than one writer exists, since the database otherwise accepts anything.

When to choose it

MongoDB fits data that arrives as nested records with varying shape, read mostly whole by key, with moderate relationships. It is a weaker fit for data with many relationships and ad hoc joins, where a relational database is simpler and the integrity checks are built in, and for money, where its transaction support has to be configured and understood. Choose it for the shape of the data, not for the lack of a schema; the schema is still there, in your code, and it has moved from the database where it was enforced to every program that reads the collection. The driver is documented in the MongoDB Go driver documentation and on pkg.go.dev.

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.