How to Perform CRUD Operations with MongoDB in Go

Connect to MongoDB in Go and perform Create, Read, Update, and Delete operations using the official driver and BSON.

The data model shifts

You're building a service that stores user profiles. Each profile has a name, an email, and a list of skills. The skills list changes size dynamically. Some users have nested preferences. A relational table with fixed columns feels rigid, so you chose MongoDB for its flexible document model. Now you have a running MongoDB instance and a Go program that needs to save, find, update, and delete these documents. The official driver handles the network protocol and BSON encoding, but you need to know how to structure the calls, manage the connection lifecycle, and handle errors without leaking resources.

Documents, collections, and the client

MongoDB stores data as BSON documents. BSON is a binary representation of JSON-like data. It supports typed fields like integers, floats, dates, and arrays, which makes it faster to parse than raw JSON text. In Go, you interact with the database through a Client. The client holds a pool of TCP connections to the server. You create the client once at startup and reuse it for the lifetime of the program.

From the client, you get a Database, and from that, a Collection. A collection is like a folder in a filing cabinet. It holds documents. You don't define the schema upfront. You just put documents in and take them out. The driver maps Go structs to BSON automatically. You write Go code with structs, and the driver translates to BSON behind the scenes. Struct tags control the mapping.

Create the client once. Pass it everywhere. Never create a client per request.

Minimal connection and insert

Here's the skeleton of a connection and a single insert. It shows the context pattern, the client lifecycle, and how to push a struct into the database.

package main

import (
	"context"
	"log"
	"time"

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

func main() {
	// Timeout prevents the program hanging if the database is unreachable.
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// Connect validates credentials and initializes the connection pool.
	client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
	if err != nil {
		log.Fatal(err)
	}
	// Disconnect returns connections to the pool and closes the socket.
	defer client.Disconnect(ctx)
}

Once connected, you grab a collection and insert a document. Struct tags map Go fields to BSON keys.

type User struct {
	// bson tags tell the driver which field names to use in the document.
	Name string `bson:"name"`
	Age  int    `bson:"age"`
}

func insertUser(ctx context.Context, client *mongo.Client) {
	coll := client.Database("app").Collection("users")
	user := User{Name: "Alice", Age: 30}

	// InsertOne encodes the struct to BSON and sends it to the server.
	result, err := coll.InsertOne(ctx, user)
	if err != nil {
		log.Fatal(err)
	}
	// result.InsertedID holds the generated ObjectId.
	log.Println("Inserted:", result.InsertedID)
}

The context.Context parameter always goes first. This is a Go convention for functions that perform I/O. The context carries deadlines and cancellation signals. If the context expires, the driver aborts the operation.

What happens under the hood

When you call mongo.Connect, the driver reaches out to the MongoDB server. It checks authentication, discovers the topology, and builds a pool of connections. The call returns quickly if the server is reachable. The actual work happens when you run operations.

InsertOne takes your User struct, looks at the bson tags, and builds a binary document. It sends this over the wire. MongoDB assigns an _id if you didn't provide one. The driver sends the result back. If you omit bson tags, the driver uses the field name as-is. Capitalized fields become capitalized keys. You usually want lowercase keys in MongoDB, so tags are standard.

The driver reuses connections from the pool. It doesn't open a new socket for every call. This keeps latency low. The pool grows and shrinks based on load. You can tune the pool size with options.Client().SetMaxPoolSize(n) if the default doesn't fit your workload.

Realistic update with filters

Real code handles errors, uses filters, and updates specific fields. Here's a function that finds a user by name and increments their age. It uses bson.D to build the filter and update document.

import (
	"fmt"

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

func updateAge(ctx context.Context, client *mongo.Client, name string, increment int) error {
	coll := client.Database("app").Collection("users")

	// Filter matches documents where the name field equals the input.
	filter := bson.D{{"name", name}}

	// Update modifies the document in place using atomic operators.
	update := bson.D{{"$inc", bson.D{{"age", increment}}}}

	result, err := coll.UpdateOne(ctx, filter, update)
	if err != nil {
		return fmt.Errorf("update failed: %w", err)
	}

	// MatchedCount tells you if a document was found and updated.
	if result.MatchedCount == 0 {
		return fmt.Errorf("user %q not found", name)
	}
	return nil
}

The $inc operator atomically increments the field. You don't need to read the document first. MongoDB handles the update on the server. MatchedCount returns zero if the filter didn't match any document. This lets you distinguish between "not found" and "database error".

Error wrapping with %w preserves the error chain. Callers can use errors.Is or errors.As to inspect the underlying error. The community accepts the if err != nil boilerplate because it makes the unhappy path visible. Don't hide errors.

BSON details: D versus M

The driver provides two types for building documents. bson.M is a map. It's convenient but unordered. bson.D is a slice of key-value pairs. It preserves order. Use bson.D for filters and updates. Order matters for some MongoDB operators and ensures the query looks the same every time. It also avoids the overhead of map allocation.

Struct tags control encoding behavior. bson:"name,omitempty" omits the field if it holds the zero value. This is essential for partial updates. If you send a struct with zero values, the driver might overwrite existing data with zeros. Use omitempty or build update documents manually to avoid accidental data loss. bson:"-" skips the field entirely. bson:"name,omitempty" helps reduce document size and keeps updates surgical.

Order matters. Use bson.D for filters and updates.

Cursors and iteration

Find doesn't load everything into memory. It returns a cursor. You pull documents one by one. This pattern prevents out-of-memory errors on large datasets.

func listUsers(ctx context.Context, client *mongo.Client) ([]User, error) {
	coll := client.Database("app").Collection("users")

	// Find returns a cursor that streams results from the server.
	cursor, err := coll.Find(ctx, bson.D{})
	if err != nil {
		return nil, err
	}
	// Close the cursor to release server resources.
	defer cursor.Close(ctx)

	var users []User
	// Next advances the cursor; Decode fills the struct.
	for cursor.Next(ctx) {
		var u User
		if err := cursor.Decode(&u); err != nil {
			return nil, err
		}
		users = append(users, u)
	}
	// Check for errors that occurred during iteration.
	if err := cursor.Err(); err != nil {
		return nil, err
	}
	return users, nil
}

The defer cursor.Close(ctx) is critical. It tells the server you're done and frees the cursor resources. cursor.Next() advances to the next document. cursor.Decode() fills the struct. cursor.Err() catches network errors that happen during iteration. If the connection drops mid-loop, Next returns false and Err holds the error. Always check Err after the loop.

Cursors stream data. Close them. Check cursor.Err().

Pitfalls and error handling

The driver returns specific error types. mongo.WriteException contains details about write failures. mongo.CommandError holds server command errors. You can check errors.Is(err, mongo.ErrNoDocuments) to detect missing documents.

If you pass a context that's already cancelled, the driver rejects the operation with context canceled. Always check the context before calling the driver. If you try to decode a document into a nil pointer, you get a panic. Initialize your structs or pointers before decoding.

FindOne returns mongo.ErrNoDocuments when the filter matches nothing. This is not a fatal error. Check for it explicitly. The compiler won't help here; it's a runtime value.

import (
	"errors"
	"go.mongodb.org/mongo-driver/mongo"
)

func handleFind(ctx context.Context, client *mongo.Client) {
	coll := client.Database("app").Collection("users")
	var user User
	err := coll.FindOne(ctx, bson.D{{"name", "Bob"}}).Decode(&user)

	if err != nil {
		// Check for the specific "not found" error.
		if errors.Is(err, mongo.ErrNoDocuments) {
			log.Println("User not found")
			return
		}
		// Log other errors and return.
		log.Printf("Database error: %v", err)
		return
	}
	log.Printf("Found user: %s", user.Name)
}

If you forget defer client.Disconnect(ctx), the process exits with open sockets. The OS cleans them up, but you'll see warnings in logs and might hit file descriptor limits in long-running processes. If you pass the request context to a background goroutine, the query cancels when the request finishes. Derive a new context with context.WithoutCancel or a fresh timeout for background work.

Check mongo.ErrNoDocuments. Wrap errors with %w.

Decision: picking the right operation

Use InsertOne when you need to add a single document and require the generated ID in the response.

Use InsertMany when writing multiple documents in a batch to reduce network overhead.

Use UpdateOne when modifying a single document identified by a unique filter.

Use UpdateMany when changing fields across multiple documents that match a query.

Use FindOne when fetching a single document and decoding it into a Go value.

Use Find when retrieving a set of documents and processing them via a cursor loop.

Use DeleteOne when removing a specific document by its ID or unique constraint.

Use DeleteMany when clearing records that satisfy a broader condition.

Pick the operation that matches your cardinality and intent.

Where to go next

If you're weighing MongoDB against a relational database, read How to Use database/sql in Go: A Complete Guide.

Connection strings look similar across drivers. See How to Use Connection String DSN in Go for the format details.

Parameterized queries prevent injection. Check How to Use Prepared Statements in Go for the pattern.