Type something to search...
MongoDB with Go: A Guide to the Official Go Driver

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.ObjectID is the v2 home of ObjectId (v1 had primitive.ObjectID). The omitempty on _id matters: without it, inserting a struct with a zero-value ID stores ObjectId("000000000000000000000000") instead of letting the driver generate one.
  • Without a tag, the driver uses the lowercased field name (CreatedAt becomes createdat). Tag every field explicitly so your stored names are predictable and match what other languages expect.
  • time.Time maps 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.Decimal128 stores exact decimal values for money. Parse with bson.ParseDecimal128("19.99").

bson.D, bson.M, bson.A, and bson.E

For filters and updates, you'll build documents directly:

TypeWhat it isUse it for
bson.DOrdered slice of key/value pairsCommands, sorts, index keys, anything where order matters
bson.Mmap[string]anyQuick filters where order doesn't matter
bson.A[]anyArrays inside documents
bson.EA single element of a bson.DBuilding 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.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading