The first connection
You are building a service that stores user preferences, event logs, or product catalogs. The data shape changes every sprint. You pick MongoDB because documents flex when your schema does. Now you need Go to talk to it. The official driver handles the network protocol, but it expects you to follow Go's rules about context, errors, and shared state. Get those right and the database feels like a natural extension of your code. Get them wrong and you leak goroutines, drown in panics, or stare at silent failures.
How the driver actually works
The go.mongodb.org/mongo-driver package is not an ORM. It does not hide the database behind magic methods. It is a direct bridge to the MongoDB wire protocol. Under the hood, it speaks BSON, which stands for Binary JSON. BSON is a compact, typed serialization format that lets the database understand integers, dates, and nested objects without parsing text.
When you create a client, the driver does not open a single TCP connection and hold it forever. It initializes a connection pool. The pool grows and shrinks based on load, up to a configurable maximum. You create one client at startup, pass it to your handlers or workers, and let the driver multiplex queries across the pool. The client is safe to share across goroutines. You do not wrap it in a mutex. You do not create a new client per request.
Context is the control plane. Every operation that touches the database accepts a context.Context as its first argument. The context carries deadlines, cancellation signals, and request-scoped values. If a query takes too long, the context tells the driver to abort the network call and return an error instead of blocking your goroutine forever.
The minimal setup
Here is the simplest way to verify that Go can reach a running MongoDB instance.
package main
import (
"context"
"log"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func main() {
// Set a hard deadline so startup fails fast if the DB is down
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() // Clean up context resources immediately after main returns
// Build the client configuration with the connection string
client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
if err != nil {
log.Fatal(err) // Abort if the driver cannot initialize
}
// Ping forces the driver to actually open a connection and verify reachability
if err = client.Ping(ctx, nil); err != nil {
log.Fatal(err) // Abort if the server rejects the handshake
}
log.Println("Connected to MongoDB")
}
Notice that mongo.Connect does not hit the network immediately. It only validates the URI and prepares the internal pool. Ping is what triggers the actual handshake. This lazy initialization keeps startup fast and lets you configure the client before committing to a network call.
What happens under the hood
When Ping runs, the driver pulls a connection from the pool, sends an ismaster or hello command, and waits for the server's reply. The context deadline governs how long that wait lasts. If ten seconds pass without a response, the driver cancels the network operation and returns a context deadline exceeded error.
The defer cancel line ensures the context's resources are released as soon as main exits. In a long-running server, you would cancel request-scoped contexts when the HTTP handler returns, but you would leave the background context alone for the application lifetime.
The client object holds references to the pool, the topology monitor, and the compression settings. Because it is designed to be shared, you typically initialize it once in main or a dedicated NewDatabaseClient function, then pass it down to your service layer. Do not recreate it per request. The pool handles concurrency for you.
A realistic workflow
Real code rarely just pings. You insert documents, query them, and handle failures. The driver maps Go structs to BSON documents using struct tags. Field names in the database do not have to match your Go field names. You control the mapping explicitly.
package main
import (
"context"
"log"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// Event represents a domain object stored in the database
type Event struct {
ID string `bson:"_id,omitempty"`
Name string `bson:"name"`
Timestamp time.Time `bson:"ts"`
Source string `bson:"source"`
}
func logEvent(ctx context.Context, client *mongo.Client, e Event) error {
// Select the collection from the default database
coll := client.Database("app").Collection("events")
// InsertOne returns an InsertedID, which we capture and discard
_, err := coll.InsertOne(ctx, e)
if err != nil {
return err // Bubble up driver or network errors
}
return nil
}
func findRecentEvents(ctx context.Context, client *mongo.Client, limit int) ([]Event, error) {
coll := client.Database("app").Collection("events")
// Build a filter that matches documents created in the last hour
filter := bson.M{"ts": bson.M{"$gte": time.Now().Add(-1 * time.Hour)}}
// Set a cursor limit and sort by timestamp descending
opts := options.Find().SetLimit(int64(limit)).SetSort(bson.D{{"ts", -1}})
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx) // Release server-side cursor resources immediately
var results []Event
if err = cursor.All(ctx, &results); err != nil {
return nil, err
}
return results, nil
}
The bson:"_id,omitempty" tag tells the driver to skip the ID field during insertion. MongoDB generates the ObjectId automatically. The omitempty directive prevents the driver from sending a zero-value string that would overwrite the database-generated ID.
Notice the error handling pattern. Every database call returns an error. The code checks it immediately and returns it. Go does not swallow failures. The verbose if err != nil block is intentional. It forces you to acknowledge the unhappy path instead of hiding it behind a panic or a silent discard.
Where things break
Database code fails in predictable ways. The driver gives you clear signals if you know where to look.
Forgetting to pass a context or passing nil causes a runtime panic. The driver requires a valid context for every operation. If you write coll.Find(nil, filter), the program crashes with runtime error: invalid memory address or nil pointer dereference. Always thread ctx through your call stack.
BSON type mismatches produce silent query failures. If your Go struct expects a string but the database stores an int, the decoder returns zero values without error. You end up with empty fields and no stack trace. Use bson.M for dynamic queries, or validate your schema at the application boundary.
Connection pool exhaustion happens when you open more concurrent queries than the pool allows. The driver defaults to a maximum pool size of 100. If your service spawns thousands of goroutines that all hit the database simultaneously, requests start waiting. You will see context deadline exceeded errors even though the database is healthy. Increase SetMaxPoolSize in options.Client() or add a semaphore to bound concurrency.
The compiler catches structural mistakes early. If you forget to import the BSON package, you get undefined: bson. If you pass a struct field that does not match the expected type, the compiler rejects it with cannot use x (type string) as type int in field value. Trust the type system. It saves you from runtime surprises.
A few conventions keep the codebase readable. context.Context always goes first, conventionally named ctx. Functions that accept a context should respect cancellation and deadlines. The receiver name for methods is usually one or two letters matching the type, like (c *Client) Ping(...). Public names start with a capital letter. Private names start lowercase. There are no public or private keywords. Visibility is purely lexical.
Goroutine leaks happen when a background task waits on a channel that never closes. Always attach a context to long-running database watchers or polling loops. When the context cancels, the goroutine should break its loop and exit. The worst goroutine bug is the one that never logs.
When to reach for MongoDB
Use MongoDB when your data shape changes frequently and you need flexible document storage without rigid schema migrations. Use PostgreSQL when you require strict relational integrity, complex joins, or ACID transactions across multiple tables. Use an in-memory store like Redis when you need sub-millisecond reads for caching or rate limiting. Use flat files or embedded databases like SQLite when you are building a single-user tool or a CLI that must run without external services. Pick the database that matches your access patterns, not the one that matches your current mood.