
MongoDB with Go: A Guide to the Official Go Driver
Go and MongoDB are a natural pair for backend services. Go gives you cheap concurrency, fast startup, and static binaries. MongoDB gives you a flexible document model that maps cleanly onto Go structs. The official driver ties them together, and it's what powers a lot of production Go services.
If you learned the driver a few years ago, though, a lot of what you remember has moved. Version 2 of the Go driver changed the import path, dropped the context argument from Connect, moved ObjectID out of the primitive package, and replaced options structs with builders. Many blog posts and Stack Overflow answers still show v1 code, which won't compile against v2.
This guide covers the v2 driver from the ground up: connecting, modeling documents with struct tags, the bson types, CRUD operations, indexes, aggregations, transactions, error handling, and the patterns that keep a Go service healthy under load.
Installing the Driver
Add the v2 module to your project:
go mod init example.com/catalog
go get go.mongodb.org/mongo-driver/v2/mongo
The /v2 suffix is part of the import path. If you see go.mongodb.org/mongo-driver/mongo without it in an example, that's v1 code.
The packages you'll use most:
import (
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
)
Connecting
In v2, mongo.Connect takes only options, no context. It doesn't perform network I/O itself; it sets up the client and starts background monitoring. Use Ping to confirm the server is actually reachable:
package main
import (
"context"
"log"
"os"
"time"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
)
func main() {
uri := os.Getenv("MONGODB_URI")
if uri == "" {
uri = "mongodb://localhost:27017"
}
opts := options.Client().
ApplyURI(uri).
SetAppName("catalog-api").
SetMaxPoolSize(50).
SetTimeout(10 * time.Second)
client, err := mongo.Connect(opts)
if err != nil {
log.Fatal(err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Disconnect(ctx); err != nil {
log.Println("disconnect:", err)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx, readpref.Primary()); err != nil {
log.Fatal("cannot reach MongoDB: ", err)
}
log.Println("connected")
}
SetTimeout configures a client-side operation timeout that applies to every operation unless the context has an earlier deadline. It's a sensible safety net so a stuck query can't hang a goroutine forever.
A *mongo.Client is safe for concurrent use and owns a connection pool. Create it once at startup and share it across your whole program, typically by passing it (or the *mongo.Collection values you derive from it) into your handlers or repository structs.
Modeling Documents with Structs
The driver maps structs to BSON using bson struct tags:
type Product struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SKU string `bson:"sku"`
Name string `bson:"name"`
Price bson.Decimal128 `bson:"price"`
Tags []string `bson:"tags,omitempty"`
Attrs map[string]string `bson:"attrs,omitempty"`
Stock int32 `bson:"stock"`
Supplier *Supplier `bson:"supplier,omitempty"`
CreatedAt time.Time `bson:"createdAt"`
}
type Supplier struct {
Name string `bson:"name"`
Country string `bson:"country"`
}
Things to know:
bson.ObjectIDis the v2 home of ObjectId (v1 hadprimitive.ObjectID). Theomitemptyon_idmatters: without it, inserting a struct with a zero-value ID storesObjectId("000000000000000000000000")instead of letting the driver generate one.- Without a tag, the driver uses the lowercased field name (
CreatedAtbecomescreatedat). Tag every field explicitly so your stored names are predictable and match what other languages expect. time.Timemaps to a BSON date, stored in UTC with millisecond precision. Values read back will have their nanoseconds truncated.- Pointers for embedded documents (
*Supplier) let you distinguish "no supplier" from "a supplier with empty fields". bson.Decimal128stores exact decimal values for money. Parse withbson.ParseDecimal128("19.99").
bson.D, bson.M, bson.A, and bson.E
For filters and updates, you'll build documents directly:
| Type | What it is | Use it for |
|---|---|---|
bson.D | Ordered slice of key/value pairs | Commands, sorts, index keys, anything where order matters |
bson.M | map[string]any | Quick filters where order doesn't matter |
bson.A | []any | Arrays inside documents |
bson.E | A single element of a bson.D | Building bson.D values |
Go maps have random iteration order, so never use bson.M for a sort specification or a compound index key; { price: 1, name: 1 } and { name: 1, price: 1 } are different indexes. Use bson.D there.
One note on style: go vet flags unkeyed struct literals from other packages, so bson.D{{"sku", 1}} triggers a warning. The official examples use the short form anyway, and many teams disable that specific check. If you'd rather keep vet clean, write bson.D{{Key: "sku", Value: 1}}. This guide uses the short form for readability.
Create: Inserting Documents
coll := client.Database("catalog").Collection("products")
price, _ := bson.ParseDecimal128("24.50")
p := Product{
SKU: "LAMP-001",
Name: "Brass Desk Lamp",
Price: price,
Tags: []string{"lighting", "office"},
Stock: 40,
CreatedAt: time.Now().UTC(),
}
res, err := coll.InsertOne(ctx, p)
if err != nil {
return err
}
id := res.InsertedID.(bson.ObjectID)
fmt.Println("inserted", id.Hex())
For many documents, InsertMany takes a slice. In v2 it accepts any slice type directly, so you can pass []Product without converting it to []any first:
products := []Product{ /* ... */ }
_, err = coll.InsertMany(ctx, products, options.InsertMany().SetOrdered(false))
SetOrdered(false) lets the server keep inserting after an individual failure, such as a duplicate key, and report all the errors at the end.
Read: Finding Documents
FindOne returns a *mongo.SingleResult; call Decode to get the struct. When nothing matches, Decode returns mongo.ErrNoDocuments:
var lamp Product
err := coll.FindOne(ctx, bson.D{{"sku", "LAMP-001"}}).Decode(&lamp)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
Find returns a cursor. Options use builder functions in v2:
filter := bson.D{
{"tags", "lighting"},
{"stock", bson.D{{"$gt", 0}}},
}
opts := options.Find().
SetSort(bson.D{{"createdAt", -1}}).
SetLimit(20).
SetProjection(bson.D{{"name", 1}, {"price", 1}, {"sku", 1}})
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
return nil, err
}
var results []Product
if err := cursor.All(ctx, &results); err != nil {
return nil, err
}
cursor.All reads every document and closes the cursor. For large result sets, iterate instead so memory stays bounded, and always close the cursor:
cursor, err := coll.Find(ctx, bson.D{})
if err != nil {
return err
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var p Product
if err := cursor.Decode(&p); err != nil {
return err
}
process(p)
}
return cursor.Err()
Forgetting cursor.Err() at the end is a classic bug: if the loop stopped because of a network error, you'd silently process a partial result.
To count, use CountDocuments for an accurate filtered count or EstimatedDocumentCount for a fast, metadata-based total of the whole collection.
Update and Delete
Updates use the regular MongoDB operators:
filter := bson.D{{"sku", "LAMP-001"}, {"stock", bson.D{{"$gte", 2}}}}
update := bson.D{
{"$inc", bson.D{{"stock", -2}}},
{"$set", bson.D{{"updatedAt", time.Now().UTC()}}},
}
res, err := coll.UpdateOne(ctx, filter, update)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrInsufficientStock
}
Upserts and "return the new version" use option builders:
opts := options.FindOneAndUpdate().
SetUpsert(true).
SetReturnDocument(options.After)
var counter struct {
Seq int64 `bson:"seq"`
}
err := client.Database("catalog").Collection("counters").FindOneAndUpdate(ctx,
bson.D{{"_id", "orderNumber"}},
bson.D{{"$inc", bson.D{{"seq", 1}}}},
opts,
).Decode(&counter)
Deletes return a count:
res, err := coll.DeleteMany(ctx, bson.D{{"stock", 0}, {"tags", "discontinued"}})
fmt.Println("deleted", res.DeletedCount)
Indexes
Create indexes with mongo.IndexModel. Run this at startup or in a migration step; creating an index that already exists with the same definition is a no-op:
models := []mongo.IndexModel{
{
Keys: bson.D{{"sku", 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{"tags", 1}, {"createdAt", -1}},
},
}
names, err := coll.Indexes().CreateMany(ctx, models)
if err != nil {
return err
}
fmt.Println(names) // [sku_1 tags_1_createdAt_-1]
Aggregations
Aggregate takes a mongo.Pipeline, which is just []bson.D:
pipeline := mongo.Pipeline{
{{"$match", bson.D{{"stock", bson.D{{"$gt", 0}}}}}},
{{"$unwind", "$tags"}},
{{"$group", bson.D{
{"_id", "$tags"},
{"products", bson.D{{"$sum", 1}}},
{"units", bson.D{{"$sum", "$stock"}}},
}}},
{{"$sort", bson.D{{"units", -1}}}},
{{"$limit", 5}},
}
cursor, err := coll.Aggregate(ctx, pipeline)
if err != nil {
return err
}
var stats []struct {
Tag string `bson:"_id"`
Products int32 `bson:"products"`
Units int32 `bson:"units"`
}
if err := cursor.All(ctx, &stats); err != nil {
return err
}
The nested braces look noisy at first, but they map one-to-one to the JSON pipeline you'd write in mongosh. A useful habit is to build and test the pipeline in mongosh or Compass, then translate it.
Transactions
Sessions and transactions changed in v2: the callback receives a regular context.Context rather than a special SessionContext. WithTransaction handles commit, abort, and retries for transient errors:
func transfer(ctx context.Context, client *mongo.Client, from, to string, qty int32) error {
inv := client.Database("catalog").Collection("inventory")
session, err := client.StartSession()
if err != nil {
return err
}
defer session.EndSession(ctx)
_, err = session.WithTransaction(ctx, func(ctx context.Context) (any, error) {
res, err := inv.UpdateOne(ctx,
bson.D{{"warehouse", from}, {"qty", bson.D{{"$gte", qty}}}},
bson.D{{"$inc", bson.D{{"qty", -qty}}}})
if err != nil {
return nil, err
}
if res.MatchedCount == 0 {
return nil, ErrInsufficientStock
}
_, err = inv.UpdateOne(ctx,
bson.D{{"warehouse", to}},
bson.D{{"$inc", bson.D{{"qty", qty}}}},
options.UpdateOne().SetUpsert(true))
return nil, err
})
return err
}
Use the ctx passed into the callback for every operation. That context carries the session; using the outer context by mistake runs the operation outside the transaction. Transactions also require a replica set or sharded cluster.
Handling Errors
The driver exposes helpers so you don't have to parse error codes by hand:
_, err := coll.InsertOne(ctx, p)
switch {
case err == nil:
// ok
case mongo.IsDuplicateKeyError(err):
return ErrSKUExists
case mongo.IsTimeout(err):
return ErrDatabaseBusy
case mongo.IsNetworkError(err):
return ErrDatabaseUnavailable
default:
return err
}
For anything more specific, errors.As into mongo.WriteException or mongo.CommandError gives you the server's error codes and messages. Retryable writes are on by default, so transient network blips during a primary election are usually retried once for you before an error ever reaches your code.
Structuring a Service
A pattern that scales well is a small repository type per collection that holds a *mongo.Collection:
type ProductStore struct {
coll *mongo.Collection
}
func NewProductStore(db *mongo.Database) *ProductStore {
return &ProductStore{coll: db.Collection("products")}
}
func (s *ProductStore) BySKU(ctx context.Context, sku string) (*Product, error) {
var p Product
err := s.coll.FindOne(ctx, bson.D{{"sku", sku}}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNotFound
}
return &p, err
}
Your HTTP handlers depend on ProductStore, pass the request's context (r.Context()) into every call, and never see a bson.D. When a client disconnects, the request context is canceled and the driver abandons the in-flight operation, which frees up the connection.
Common Mistakes
Using v1 examples with the v2 driver. Symptoms: primitive.ObjectID not found, mongo.Connect wanting a context, options structs that don't exist. Check the import path first.
Using bson.M where order matters. Sort specs and index keys built from maps come out in random order, which gives you different sort results or unintended indexes. Use bson.D.
Missing omitempty on _id. Every insert gets the zero ObjectId, and the second insert fails with a duplicate key error that looks baffling at first.
Creating a client per request. Each client has its own pool and monitoring goroutines. Create one at startup and inject it.
Ignoring contexts. Passing context.Background() everywhere means canceled requests keep running queries. Thread the request context through, and set a client-wide timeout as a backstop.
Not checking cursor.Err(). A loop that ends early due to an error looks exactly like a loop that finished.
Conclusion
The Go driver v2 is a clean fit for idiomatic Go: one shared client, struct tags for mapping, bson.D for anything ordered, contexts for cancellation, and helper functions for error handling. The breaking changes from v1 are mostly mechanical, and once you've internalized the new import path and option builders, the rest of the API reads naturally.
As a next step, take one handler in your service, give it a ProductStore-style repository with the request context threaded through, and set SetTimeout on the client. You'll get cancellation and bounded latency for free on every query that follows.


