A NoSQL (Not Only SQL) database is a type of database that stores and manages data in non-relational formats. Unlike relational (SQL) databases that use tables, rows, and columns, NoSQL databases use flexible schemas and different storage models such as:
- Documents
- Key-value pairs
- Wide-column stores
- Graph structures
NoSQL databases were created to handle large volumes of unstructured or semi-structured data, support horizontal scaling, and power high-performance applications like social networks, e-commerce, and real-time analytics.
Traditional SQL databases struggled with:
- Rapidly growing data (big data)
- Unstructured data (JSON, logs, images, sensors)
- Need for massive scale (millions of users)
- Distributed architectures (global apps)
- Real-time performance
NoSQL solved these problems by offering:
- Schema flexibility
- Horizontal sharding
- High write/read throughput
- Easy scaling across servers
We don’t need to predefine columns. For example, in MongoDB, we can insert documents with different fields.
Instead of upgrading one big server (vertical scaling), NoSQL allows us to scale by adding more servers (horizontal).
Optimized for fast reads and writes.
Built for replication + sharding across multiple nodes.
Perfect for JSON, logs, API responses, social media, IoT data.
- Store data in JSON-like documents.
- Flexible schema.
- Best for: APIs, user profiles, content management.
Example Document (MongoDB):
{
"name": "Skyy",
"age": 29,
"skills": ["React", "Node.js"],
"location": "Kolkata"
}- Simplest type: data stored as
{ key : value }. - Super fast.
- Best for: caching, sessions, real-time counters.
- Data stored in rows and dynamic columns.
- Designed for massive write throughput.
- Best for: analytics, log data, IoT.
- Data stored as nodes and edges.
- Best for: social networks, recommendations, relationships.
| Feature | SQL | NoSQL |
|---|---|---|
| Structure | Tables, rows, columns | Documents, key-value, graphs |
| Schema | Fixed schema | Flexible schema |
| Scaling | Vertical | Horizontal |
| ACID transactions | Strong support | Limited (but improving) |
| Query language | SQL | Varied (MongoDB queries, GraphQL-like, etc.) |
| Best for | Structured data | Unstructured/BIG data |
| Example | MySQL, PostgreSQL | MongoDB, Redis, Cassandra |
✔ We need scalability ✔ Our data is unstructured ✔ Schema changes often ✔ Massive amounts of data ✔ Real-time performance is needed ✔ We want a cloud-native, distributed system
✔ Data is structured and relational ✔ Complex transactions are needed ✔ Banking-like ACID guarantees ✔ Relationships are strong
Splitting data into shards across multiple servers.
Copying data to multiple nodes for availability.
No database can provide all three perfectly:
- Consistency
- Availability
- Partition tolerance
NoSQL databases choose different trade-offs.
NoSQL databases create indexes to speed up queries.
- Most popular NoSQL database
- Document model (JSON)
- Flexible schema
- Great for MERN/MEAN stack apps
- Used in: e-commerce, SaaS, IoT, healthcare apps
- In-memory
- Extremely fast
- Used for caching, rate limiting, sessions
- Wide-column
- Extremely scalable
- Great for big data and analytics pipelines
- AWS-managed NoSQL
- Serverless scaling
- Key-value + document store
- Graph database
- Ideal for relationships (friends, followers)
- Instagram feeds
- Amazon product recommendations
- YouTube metadata
- Uber ride tracking
- Netflix streaming preferences
- E-commerce cart systems
- Real-time chats and notifications
Because we’re working with MERN and MongoDB Atlas, here are MongoDB-specific insights:
NoSQL databases are:
- Flexible
- Scalable
- Fast
- Designed for modern web + mobile apps
- Great for unstructured data
- Often easier to use with JavaScript/Node.js
They are not:
- A replacement for SQL in all cases
- Ideal for complex transactions
- Best for strongly relational data
MongoDB is the world’s most popular NoSQL, document-oriented, distributed database, designed for modern applications that need flexibility, high performance, horizontal scaling, and JSON-based data modeling.
Let’s break down EVERYTHING step-by-step.
MongoDB is a NoSQL document database that stores data in documents, not tables.
- Uses JSON-like structure called BSON
- Schema is flexible (no strict tables/columns)
- Built to scale horizontally across servers
- Excellent for real-time, high-traffic, cloud-native apps
Because it stores data in JSON-like structures, it's perfect for JavaScript developers (like us), especially in the MERN stack.
| Feature | MongoDB | SQL (MySQL, PostgreSQL) |
|---|---|---|
| Data Model | Documents (JSON) | Tables, rows, columns |
| Schema | Flexible | Fixed |
| Scaling | Horizontal | Vertical |
| Joins | Limited (lookup) | Strong support |
| Transactions | Supported (from v4) | Default |
| Performance | Very fast for reads/writes | Good but rigid |
| Ideal For | Modern apps, APIs, unstructured data | Relational data |
A MongoDB cluster has multiple databases.
Equivalent of “tables” in SQL, but schema-less.
Equivalent of “rows”, but much more flexible.
Example Document:
{
"name": "Skyy",
"age": 29,
"profession": "Software Engineer",
"skills": ["React", "Node.js"],
"location": {
"city": "Kolkata",
"country": "India"
}
}MongoDB stores data as Binary JSON (BSON) which adds more data types:
- Date
- ObjectId
- Binary
- Decimal128
- Boolean
MongoDB Atlas is MongoDB's fully managed cloud service, offering:
- Automatic scaling
- Backups
- Monitoring
- Global clusters
- Integrated security
- Extremely easy database deployment
Most MERN developers use Atlas; we already use it in our projects.
CRUD = Create, Read, Update, Delete
db.users.insertOne({ name: "Skyy", age: 29 })db.users.find({ age: 29 })db.users.updateOne({ name: "Skyy" }, { $set: { age: 30 } })db.users.deleteOne({ name: "Skyy" })MongoDB uses operators like $set, $push, $pull, $inc, $gt, $lt, $regex etc.
Indexes make queries fast.
db.users.createIndex({ email: 1 }) // ascending indexIndexes:
- Improve query speed
- Increase write cost (because they update per insert)
- Essential for large-scale apps
Used for advanced queries, analytics, transformations.
Example:
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $group: { _id: "$userId", total: { $sum: "$amount" } } }
])Steps called stages:
- $match
- $group
- $sort
- $project
- $lookup (join)
- $limit
- $addFields
Aggregation = powerful alternative to SQL joins and stored procedures.
MongoDB does not force strict relationships, but supports:
Best for: small, related data
{
"name": "Skyy",
"orders": [
{ "product": "Shoes", "price": 1200 },
{ "product": "Shirt", "price": 800 }
]
}Best for: large or frequently changing data
{
"userId": ObjectId("..."),
"productId": ObjectId("...")
}{
$lookup: {
from: "products",
localField: "productId",
foreignField: "_id",
as: "productDetails"
}
}MongoDB supports sharding, which splits data across servers.
Benefits:
- Huge performance boosts
- Supports massive databases
- Perfect for global-scale apps
Shard keys decide how data is distributed.
MongoDB replica sets include:
- Primary (writes)
- Secondary (read-only copies)
- Automatic failover
If primary goes down → a secondary becomes primary automatically.
Since version 4.0, MongoDB supports multi-document ACID transactions, like SQL databases.
const session = client.startSession();
session.startTransaction();
try {
await users.updateOne(...);
await orders.updateOne(...);
await session.commitTransaction();
} catch (err) {
await session.abortTransaction();
}Used for:
- Payments
- Banking
- Inventory
Most MERN apps use Mongoose, a popular ODM library.
const UserSchema = new mongoose.Schema({
name: String,
email: { type: String, required: true, unique: true },
age: Number
});const User = mongoose.model("User", UserSchema);await User.find({ age: { $gte: 18 } })Mongoose adds:
- Validation
- Middleware
- Schemas
- Query helpers
MongoDB powers:
- E-commerce: carts, products, orders
- Healthcare systems
- Social media feeds
- Real-time chats
- IoT data collection
- Logistics and tracking
- Multi-tenant SaaS apps
- Streaming metadata
- Analytics dashboards
✔ Always index fields used in searches
✔ Use embedding for small related data
✔ Use referencing for large data
✔ Avoid deep nested documents
✔ Use sharding when database grows
✔ Validate schema using Mongoose
✔ Use .lean() in queries to speed up reads
✔ Keep documents under 16MB
MERN = MongoDB + Express + React + Node
Flow:
- React → sends request
- Express/Node → receives & validates
- MongoDB → stores, retrieves data
MongoDB fits naturally because:
- Frontend uses JSON
- Backend uses JSON
- Database stores JSON
Perfect match.
MongoDB is:
- a NoSQL document database
- that stores flexible JSON-like documents
- scales horizontally
- is extremely fast
- easy for JavaScript developers
- perfect for modern cloud apps
- used widely in the MERN stack
BSON = Binary JSON It is a binary-encoded data format designed by MongoDB to store documents efficiently and make reading, writing, and searching extremely fast.
Even though the name suggests “Binary JSON”, BSON is not identical to JSON. Instead, BSON:
- Supports more data types than JSON
- Stores data in a binary format instead of plain text
- Is optimized for speed (fast scanning, fast indexing)
- Is optimized for space (most fields encoded compactly)
MongoDB uses BSON internally for:
- Storing documents on disk
- Exchanging data between server ↔ drivers
- Representing documents in memory
- Index storage
- Query processing
JSON is human-readable, but:
| Limitation | Problem |
|---|---|
| No variety of numeric types | JSON has only one number type (double precision). |
| No efficient binary format | JSON stores everything as text, wastes space, slow to parse. |
| No dates | JSON has only strings, not actual date types. |
| No 32-bit ints or 64-bit ints | Required for precision in databases. |
| No object ID type | MongoDB needed something unique per document. |
| No efficient traversal | JSON requires parsing every character. |
BSON adds:
- 32-bit integers
- 64-bit integers
- High-precision decimal128
- Binary data
- ObjectId
- Date type
- Timestamps
- Boolean
- Regex
- MinKey, MaxKey
- Arrays
- Embedded subdocuments
…and stores them in a compact binary form.
Every BSON document has:
<total document size in bytes>
<field1 type> <field1 name> <field1 value>
<field2 type> <field2 name> <field2 value>
...
<null terminator>
JSON:
{ "name": "John", "age": 25 }Internal BSON representation (conceptually):
16 → total byte size
02 6E 61 6D 65 00 → type=string, field name='name'
04 00 00 00 4A 6F 68.. → length + "John"
10 61 67 65 00 → type=int32, field=age
19 00 00 00 → age = 25
00 → null terminator
MongoDB can jump directly to fields because each field starts with a type identifier.
Here is the full list of BSON types used in MongoDB:
Used for numeric values with decimals.
Example:
{ price: 25.75 }UTF-8 encoded.
{ name: "Laptop" }Documents inside documents.
{ user: { name: "John", age: 30 } }List of values.
{ tags: ["electronics", "gaming"] }Arbitrary byte data (e.g., images, encrypted data).
{ file: <Binary Data> }MongoDB GridFS uses this for large files.
MongoDB’s default primary key. 12-byte structure:
| Bytes | Meaning |
|---|---|
| 4 | timestamp |
| 5 | machine + process identifier |
| 3 | random increment counter |
Example:
ObjectId("65c4321fea8902bb139a77a2")
{ isAvailable: true }Stored as milliseconds since Unix epoch.
{ createdAt: new Date() }{ middleName: null }{ name: /John/i }Used in server-side scripts, but mostly avoided now.
Used for small integers.
{ quantity: 10 }Used internally for replication.
Not the same as Date.
Needed for large integers.
High precision decimals. Used for:
- Money
- Banking
- Scientific calculations
{ price: NumberDecimal("9999.99") }Used to compare values. Special types used in queries.
Binary structure allows jumping through fields without parsing character-by-character like JSON.
Binary encoding reduces space usage (especially for numbers & dates).
Essential for databases—especially numeric precision.
BSON is designed to be decoded quickly, improving query performance.
MongoDB indexes store values in BSON format.
| Feature | JSON | Extended JSON | BSON |
|---|---|---|---|
| Human-readable | ✔️ | ✔️ | ❌ |
| Machine-efficient | ❌ | ❌ | ✔️ |
| Has dates | ❌ | ✔️ | ✔️ |
| Has binary data | ❌ | ✔️ | ✔️ |
| Has ObjectId | ❌ | ✔️ | ✔️ |
Extended JSON example:
{ "_id": { "$oid": "65f123a2..." } }When we insert a document:
db.users.insertOne({ name: "John", age: 25 });Shell shows JSON. But on disk, it is stored as BSON.
The shell automatically converts BSON → JSON-like output for readability.
For serious MongoDB development, BSON knowledge helps us:
- BSON is Binary JSON, optimized for speed and storage.
- It adds many extra data types beyond JSON.
- MongoDB stores everything internally in BSON.
- BSON is not human readable, but the shell converts it for us.
- BSON helps MongoDB become fast, flexible, and scalable.
Below is the most complete, deeply detailed, interview-level explanation of indexing in MongoDB — from fundamentals to advanced tuning, covering internals, cost, types, pitfalls, and best-practices.
We’ll explore everything:
- What an index is and how MongoDB stores it internally
- Types of indexes
- Covered queries
- Multikey indexing
- Partial and sparse indexes
- TTL, hashed, text, geo indexes
- How indexing affects performance
- When indexes hurt us
- Collations, cardinality, selectivity
- How index usage is chosen
- Compound index rules
- Practical patterns for real-world apps (MERN, eCommerce, logging systems, etc.)
A MongoDB index is a special ordered data structure stored separately from documents in a collection. It behaves like the index of a book: instead of scanning the entire book, MongoDB jumps directly to the page where the value is located.
Internally:
- Balanced tree
- Sorted by index key
- Fast lookups (logarithmic time complexity: O(log n))
- Supports range queries efficiently (
$gt,$lt,$gte,$lte)
(key) → pointer to actual document location
Because they must be fast.
If our working set (frequently accessed data) + indexes > available RAM → performance drops.
MongoDB does COLLSCAN → collection scan → checks every document in the collection → slow when the collection is large (millions of docs)
MongoDB performs IXSCAN → index scan → quickly jumps to relevant documents → extremely fast
db.users.createIndex({ email: 1 });1= ascending-1= descending- For single-field indexes ascending/descending doesn't matter.
db.users.getIndexes();db.users.dropIndex("email_1");db.products.createIndex({ price: 1 });Helps queries like:
db.products.find({ price: { $gt: 500 } });
db.products.find().sort({ price: 1 });A compound index indexes multiple fields in one index.
Example:
db.users.createIndex({ age: 1, city: 1 });This index will support queries on:
{ age: value }{ age: value, city: value }- Range queries on age followed by equality on city
{ city: value }
— because the index order matters.
🔥 “Prefix Rule” / Leftmost Prefix Rule
For an index:
{ a: 1, b: 1, c: 1 }It supports:
aa, ba, b, c
BUT not:
bb, cca, c(skips b)
MongoDB must follow the order.
Query:
db.orders.find({ userId: 13, status: "Pending" }).sort({ createdAt: -1 })Best index:
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });Why?
- Equality → comes first (userId & status)
- Then sort → createdAt
If a document contains:
{ tags: ["tech", "coding", "ai"] }MongoDB automatically creates one index entry per array value.
db.articles.createIndex({ tags: 1 });Supported queries:
db.articles.find({ tags: "ai" });- Only one multikey field per compound index unless using different subpaths.
- Range queries can become expensive.
db.posts.createIndex({ content: "text" });Supports:
db.posts.find({ $text: { $search: "mongodb indexing" } });- stemming (
running→run) - stop words removal
- case-insensitive
- multilingual support with special analyzers
- Only one text index allowed per collection.
- Cannot mix text with non-text fields in same index except as metadata fields.
Used for sharding, equal distribution of keys.
db.users.createIndex({ userId: "hashed" });Pros:
- Avoids hotspot issues
- Good for equality lookups
Cons:
- ❌ Cannot support range queries
- ❌ Cannot be used for sorting
Index only documents that match a filter.
db.users.createIndex(
{ email: 1 },
{ partialFilterExpression: { emailVerified: true } }
);Benefits:
- Smaller index
- Faster writes
- Lower memory usage
Perfect for:
- soft-deleted data
- sparse categories
- flags (like verified users)
Does not index documents where the field is missing or null.
db.users.createIndex({ phone: 1 }, { sparse: true });Good when:
- Not all docs have the key
- Field appears rarely
⚠ Danger: Sparse searches may return no results if queried incorrectly.
db.users.createIndex({ email: 1 }, { unique: true });Prevents duplicates.
Time-to-live → auto-delete documents.
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 });Use cases:
- sessions
- logs
- temp data
A query is covered when:
- All fields in filter are in index
- All fields returned are in index
- No need to touch the document on disk → super fast
Example:
db.users.createIndex({ email:1, age:1 });
db.users.find({ email: "a@b.com" }, { email:1, age:1, _id:0 });MongoDB serves the query entirely from the index → best performance.
Sorting uses indexes only when index prefix matches sort order.
Example:
db.products.createIndex({ price: 1 });Sort works:
db.products.find().sort({ price: 1 });Does NOT work:
db.products.find().sort({ rating: 1 }); // full in-memory sortMongoDB runs a query planner:
- Tests candidate indexes
- Predicts cost
- Picks the cheapest index plan
Use .explain() to see what’s happening:
db.users.find({ age: 19 }).explain("executionStats")Look for:
stage: "IXSCAN"→ goodstage: "COLLSCAN"→ badnReturned,nExaminedtotalKeysExamined,totalDocsExamined
Ideally:
totalDocsExamined = 0
On insert/update/delete:
- MongoDB updates index entries
- More indexes → slower writes
Index too large → evicts our working set → performance tanks
Index { age:1, city:1 } is useless for queries on { city }.
More indexes ≠ better performance.
→ Better performance & fewer index scans
Order fields in an index like:
- equality filters first
- sort fields next
- range queries last
Bad: gender, active flag, boolean, category if small set.
(forcing wrong index can break production)
Query:
db.orders.find({
userId: 123,
status: "Delivered",
total: { $gte: 500 }
}).sort({ createdAt: -1 });Best compound index:
db.orders.createIndex({
userId: 1, // equality
status: 1, // equality
createdAt: -1, // sorting
total: 1 // range
});Follows ESR rule:
- equality fields first
- sort fields next
- range fields last
| Feature | Purpose |
|---|---|
| Single Index | Simple lookups |
| Compound Index | Optimizes multi-field queries |
| Multikey Index | Arrays |
| Partial Index | Only some docs indexed |
| Sparse Index | Skip missing fields |
| Unique Index | Prevent duplicates |
| TTL Index | Auto-delete docs |
| Text Index | Full-text search |
| Hashed Index | Sharding, equality lookup |
| Covered Query | Extreme speed |
Let’s build a solid mental model and a working blueprint for using MongoDB as the database with Golang + Gin. We’ll cover architecture, best practices, code you can copy, production concerns (pooling, timeouts, TLS), transactions, testing tips, and a recommended project layout.
Below are a few authoritative references we leaned on while preparing this: the official MongoDB Go driver docs (connect & tutorials), the official tutorial that pairs Gin + Go driver, connection-pool guidance, and transaction docs. ([mongodb.com][1])
- Single global Mongo client: create one
*mongo.Clientper process and reuse it. The client manages connection pools under the hood. Creating a new client per request is expensive and will exhaust connections. ([mongodb.com][1]) - Context per operation: use
context.Contextwith reasonable timeouts for each DB call; never reuse a cancelled context. Timeouts protect your app and avoid leaked goroutines. ([Go Packages][2]) - Dependency injection into handlers: inject the DB (or collections) into Gin handlers via middleware or an application struct.
- Graceful shutdown: disconnect the client on shutdown to allow the driver to close pools cleanly. ([mongodb.com][1])
go get go.mongodb.org/mongo-driver/mongo
go get github.com/gin-gonic/gin(Driver docs & examples use go.mongodb.org/mongo-driver.) ([GitHub][3])
package db
import (
"context"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"log"
)
func Connect(ctx context.Context, uri string) (*mongo.Client, error) {
// ctx should have timeout (e.g., 10-20s) when calling Connect
clientOpts := options.Client().ApplyURI(uri)
// configure pool if needed:
// clientOpts.SetMaxPoolSize(100)
client, err := mongo.Connect(ctx, clientOpts)
if err != nil {
return nil, err
}
// Ping to verify connection
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := client.Ping(pingCtx, nil); err != nil {
return nil, err
}
return client, nil
}
func Disconnect(ctx context.Context, client *mongo.Client) error {
return client.Disconnect(ctx)
}Notes: pass an explicit timeout on the Connect call (we usually use 10–20s). Ping verifies connectivity. ([Go Packages][2])
Approach A — application struct (explicit DI):
type App struct {
Router *gin.Engine
DB *mongo.Database
}
func NewApp(db *mongo.Database) *App {
r := gin.Default()
a := &App{Router: r, DB: db}
// register handlers with closures capturing a.DB
r.GET("/users/:id", a.getUser)
return a
}Approach B — Gin middleware that attaches DB or collections to *gin.Context:
func DBMiddleware(db *mongo.Database) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("db", db) // or set specific collection
c.Next()
}
}Then in handler:
db := c.MustGet("db").(*mongo.Database)
users := db.Collection("users")We prefer the app-struct pattern for type-safety and ease of testing, but middleware is quick for small apps.
Define models with BSON tags for Mongo encoding/decoding:
type User struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name" binding:"required"`
Email string `bson:"email" json:"email" binding:"required,email"`
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
}- Use
primitive.ObjectIDfor_id. - Use Gin’s
bindingtags for request validation, and keep BSON/JSON tags in sync where possible.
Insert document:
func createUser(ctx context.Context, coll *mongo.Collection, u *User) (*mongo.InsertOneResult, error) {
u.CreatedAt = time.Now().UTC()
return coll.InsertOne(ctx, u)
}Find one by id:
func getUserByID(ctx context.Context, coll *mongo.Collection, idStr string) (*User, error) {
id, err := primitive.ObjectIDFromHex(idStr)
if err != nil { return nil, err }
var u User
err = coll.FindOne(ctx, bson.M{"_id": id}).Decode(&u)
if err == mongo.ErrNoDocuments { return nil, nil }
return &u, err
}Find many (cursor):
cur, err := coll.Find(ctx, bson.M{"active": true})
defer cur.Close(ctx)
for cur.Next(ctx) {
var u User
if err := cur.Decode(&u); err != nil { ... }
}Update:
res, err := coll.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": bson.M{"name": "New"}})Delete:
res, err := coll.DeleteOne(ctx, bson.M{"_id": id})Remember: always pass a context with timeout for each operation.
- Create indexes for fields used in filters/sorts (e.g.,
emailunique index). Creating indexes at app startup is common for small apps, or via migrations for larger apps. - Use
coll.Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{"email", 1}}, Options: options.Index().SetUnique(true) }). - Tune
maxPoolSize,minPoolSizewhen you have predictable concurrency/throughput needs; the driver manages pools internally but we can override defaults. ([mongodb.com][4])
- Transactions require a replica set or sharded cluster; single-node standalone does not support them. Use transactions only when atomicity across multiple documents/collections is required. ([mongodb.com][5])
Example:
session, err := client.StartSession()
if err != nil { ... }
defer session.EndSession(ctx)
callback := func(sessCtx mongo.SessionContext) (interface{}, error) {
if _, err := collA.InsertOne(sessCtx, docA); err != nil { return nil, err }
if _, err := collB.UpdateOne(sessCtx, filterB, updateB); err != nil { return nil, err }
return nil, nil
}
_, err = session.WithTransaction(ctx, callback)The driver takes care of retries for transient errors if you use WithTransaction. Test transaction flows carefully. ([mongodb.com][5])
- Pool: defaults are reasonable; adjust
MaxPoolSizewhen connection limits or concurrency demand it. Monitor pool wait times to detect undersizing. ([mongodb.com][6]) - Socket / operation timeouts: set operation-level timeouts (via contexts). Avoid very short timeouts for large queries.
- Network resilience: implement retry logic for transient network errors; the Go driver automatically handles some retries (e.g.,
WithTransaction), but explicit retries for idempotent ops may be required. - DNS SRV with Atlas: if using Atlas, use the SRV URI (starting
mongodb+srv://) and enable TLS.
- Use environment variables (or secret manager) for credentials/URIs.
- Use TLS; Atlas enforces TLS by default.
- Limit DB user permissions — prefer least privilege.
- For production on Kubernetes / containers: mount CA certs if needed, set connection limits based on pod replicas and DB max connections. Monitor metrics (MongoSI/Atlas metrics). ([mongodb.com][1])
- Integration tests: use a real MongoDB instance spun up in CI — options: MongoDB Atlas ephemeral projects, Testcontainers (docker) for local/CI, or a dedicated test cluster.
- Unit tests: abstract DB access behind interfaces so we can use fake implementations in unit tests. Example:
type UserStore interface {
Insert(ctx context.Context, u *User) error
FindByID(ctx context.Context, id string) (*User, error)
}- The official driver does not provide an in-memory replacement, so use DI + fakes or testcontainers. (This keeps tests deterministic and fast.) ([DigitalOcean][7])
/cmd/app/main.go
/internal/app/app.go // wire router and services
/internal/db/mongo.go // connect/disconnect, client wrapper
/internal/models/user.go
/internal/store/user_store.go // interacts with mongo.Collection
/internal/handlers/user.go
/internal/migrations/* // index creation, migrations
/pkg/config/config.go // env loader
Pros: clear separation of concerns, easy to mock store layer in tests.
srv := &http.Server{
Addr: ":8080",
Handler: router,
}
go func(){ srv.ListenAndServe() }()
quit := make(chan os.Signal)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
srv.Shutdown(ctx)
db.Disconnect(ctx) // mongo client disconnectAlways call client.Disconnect(ctx) so the driver closes connections/pools cleanly. ([mongodb.com][1])
- Reusing cancelled contexts causes
context cancelederrors. Create a fresh context per operation. ([mongodb.com][8]) - Creating many clients (one per request) exhausts connections — use singleton client. ([mongodb.com][1])
- Forgetting indexes leads to slow queries at scale — create index migrations early. ([mongodb.com][4])
Connect()on app startup with timeout andPing. ([mongodb.com][1])- Build an
Appstruct with*mongo.Databaseand attach Gin routes. - For each request, create
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)and defercancel(). Use that ctx for DB calls. - Use
coll.FindOne(ctx, filter).Decode(&result)patterns. - Indexes and pool tuning as app scales. ([mongodb.com][6])
Now, let’s add JWT auth to our Go + Gin + MongoDB app. Below we’ll build a secure, production-minded flow with:
- signup (hash passwords with bcrypt)
- login (issue access + refresh tokens)
- refresh token endpoint (rotate + persist refresh tokens)
- middleware that protects routes by validating access tokens
- logout (revoke refresh token)
- secure configuration and deployment notes
We'll use a hybrid approach: stateless access tokens (short-lived JWTs) + stateful refresh tokens stored in MongoDB so we can revoke/rotate them. This gives good UX and enables session invalidation.
go get github.com/gin-gonic/gin
go get go.mongodb.org/mongo-driver/mongo
go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt(We use github.com/golang-jwt/jwt/v5 for JWT handling, bcrypt for password hashing.)
Put secrets and expiries into environment variables (or secret manager):
JWT_ACCESS_SECRET=super-secret-access-key
JWT_REFRESH_SECRET=super-secret-refresh-key
JWT_ACCESS_EXPIRE_MINUTES=15
JWT_REFRESH_EXPIRE_DAYS=7
We’ll read them in a config struct in code.
// models/user.go
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type User struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Email string `bson:"email" json:"email"`
Password string `bson:"password,omitempty" json:"-"`
Name string `bson:"name" json:"name"`
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
}
// models/token.go
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type RefreshTokenRecord struct {
ID primitive.ObjectID `bson:"_id,omitempty"`
Token string `bson:"token"`
UserID primitive.ObjectID `bson:"userId"`
ExpiresAt time.Time `bson:"expiresAt"`
CreatedAt time.Time `bson:"createdAt"`
Revoked bool `bson:"revoked"`
ReplacedBy string `bson:"replacedBy,omitempty"`
}package auth
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}We define claims and functions for signing/validating access and refresh tokens.
package auth
import (
"time"
"errors"
jwt "github.com/golang-jwt/jwt/v5"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type JWTManager struct {
AccessSecret []byte
RefreshSecret []byte
AccessTTL time.Duration
RefreshTTL time.Duration
}
type AccessClaims struct {
UserID string `json:"uid"`
jwt.RegisteredClaims
}
type RefreshClaims struct {
UserID string `json:"uid"`
jwt.RegisteredClaims
}
func NewJWTManager(accessSecret, refreshSecret string, accessTTL, refreshTTL time.Duration) *JWTManager {
return &JWTManager{
AccessSecret: []byte(accessSecret),
RefreshSecret: []byte(refreshSecret),
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
}
}
func (m *JWTManager) GenerateAccessToken(userID primitive.ObjectID) (string, time.Time, error) {
expiresAt := time.Now().Add(m.AccessTTL)
claims := AccessClaims{
UserID: userID.Hex(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: userID.Hex(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(m.AccessSecret)
return signed, expiresAt, err
}
func (m *JWTManager) GenerateRefreshToken(userID primitive.ObjectID) (string, time.Time, error) {
expiresAt := time.Now().Add(m.RefreshTTL)
claims := RefreshClaims{
UserID: userID.Hex(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: userID.Hex(),
ID: primitive.NewObjectID().Hex(), // unique id for rotation tracking
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(m.RefreshSecret)
return signed, expiresAt, err
}
func (m *JWTManager) VerifyAccessToken(tokenStr string) (*AccessClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &AccessClaims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method")
}
return m.AccessSecret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*AccessClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
return claims, nil
}
func (m *JWTManager) VerifyRefreshToken(tokenStr string) (*RefreshClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &RefreshClaims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method")
}
return m.RefreshSecret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*RefreshClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
return claims, nil
}We store refresh tokens so we can revoke or rotate them.
package store
import (
"context"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"myapp/models"
)
type TokenStore struct {
col *mongo.Collection
}
func NewTokenStore(db *mongo.Database, collName string) *TokenStore {
return &TokenStore{col: db.Collection(collName)}
}
func (s *TokenStore) SaveRefreshToken(ctx context.Context, token string, userID primitive.ObjectID, expiresAt time.Time) error {
rec := models.RefreshTokenRecord{
Token: token,
UserID: userID,
ExpiresAt: expiresAt,
CreatedAt: time.Now(),
Revoked: false,
}
_, err := s.col.InsertOne(ctx, rec)
return err
}
func (s *TokenStore) RevokeRefreshToken(ctx context.Context, token string) error {
_, err := s.col.UpdateOne(ctx, bson.M{"token": token}, bson.M{"$set": bson.M{"revoked": true}})
return err
}
func (s *TokenStore) FindValidToken(ctx context.Context, token string) (*models.RefreshTokenRecord, error) {
var rec models.RefreshTokenRecord
err := s.col.FindOne(ctx, bson.M{"token": token, "revoked": false}).Decode(&rec)
if err != nil {
return nil, err
}
if rec.ExpiresAt.Before(time.Now()) {
return nil, mongo.ErrNoDocuments
}
return &rec, nil
}We should create TTL indexes on expiresAt and maybe an index on token for fast lookup:
s.col.Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{"expiresAt", 1}},
Options: options.Index().SetExpireAfterSeconds(0),
})Assume userStore implements user creation & lookup. We'll sketch handlers.
// handlers/auth.go
package handlers
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson/primitive"
"myapp/auth" // JWTManager + helpers
"myapp/store" // TokenStore, UserStore
"myapp/models"
)
type AuthHandler struct {
JWT *auth.JWTManager
Users *store.UserStore
Tokens *store.TokenStore
}
type registerReq struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
func (h *AuthHandler) Register(c *gin.Context) {
var req registerReq
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
// check existing
if exists, _ := h.Users.ExistsByEmail(ctx, req.Email); exists {
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
return
}
hashed, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
user := &models.User{
Email: req.Email,
Password: hashed,
Name: req.Name,
CreatedAt: time.Now(),
}
newID, err := h.Users.Create(ctx, user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": newID.Hex()})
}
type loginReq struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
func (h *AuthHandler) Login(c *gin.Context) {
var req loginReq
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
user, err := h.Users.FindByEmail(ctx, req.Email)
if err != nil || user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
uid := user.ID
accessTok, accessExp, err := h.JWT.GenerateAccessToken(uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "couldn't create access token"})
return
}
refreshTok, refreshExp, err := h.JWT.GenerateRefreshToken(uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "couldn't create refresh token"})
return
}
// persist refresh token
if err := h.Tokens.SaveRefreshToken(ctx, refreshTok, uid, refreshExp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store token"})
return
}
// return tokens; recommended: put refresh token in httpOnly secure cookie
c.JSON(http.StatusOK, gin.H{
"access_token": accessTok,
"expires_at": accessExp.UTC().Format(time.RFC3339),
"refresh_token": refreshTok, // opt: omit if using cookie
})
}
type refreshReq struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
func (h *AuthHandler) Refresh(c *gin.Context) {
var req refreshReq
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
claims, err := h.JWT.VerifyRefreshToken(req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
// verify token exists & not revoked
rec, err := h.Tokens.FindValidToken(ctx, req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token not found or expired"})
return
}
// rotate: revoke old token and issue new refresh token
if err := h.Tokens.RevokeRefreshToken(ctx, req.RefreshToken); err != nil {
// log error but continue maybe
}
uid, _ := primitive.ObjectIDFromHex(claims.UserID)
newRefresh, newRefreshExp, err := h.JWT.GenerateRefreshToken(uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"})
return
}
if err := h.Tokens.SaveRefreshToken(ctx, newRefresh, uid, newRefreshExp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save refresh token"})
return
}
// issue new access token
accessTok, accessExp, err := h.JWT.GenerateAccessToken(uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": accessTok,
"expires_at": accessExp.UTC().Format(time.RFC3339),
"refresh_token": newRefresh,
})
}
type logoutReq struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
func (h *AuthHandler) Logout(c *gin.Context) {
var req logoutReq
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
if err := h.Tokens.RevokeRefreshToken(ctx, req.RefreshToken); err != nil {
// ignore or return error
}
c.Status(http.StatusNoContent)
}Notes:
- Prefer to return refresh token in an HttpOnly Secure cookie instead of JSON to reduce XSS risk. If we do cookies, we must set
SameSiteappropriately and use HTTPS. - Rotate refresh tokens: revoke old and store new token; store
replacedByif you want to trace.
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"myapp/auth"
)
func AuthRequired(jwtManager *auth.JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authorization header required"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authorization header format must be Bearer {token}"})
return
}
token := parts[1]
claims, err := jwtManager.VerifyAccessToken(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
c.Set("userID", claims.UserID)
c.Next()
}
}In handlers we can get user id:
uidStr, _ := c.Get("userID")func main() {
// load env
accessSecret := os.Getenv("JWT_ACCESS_SECRET")
refreshSecret := os.Getenv("JWT_REFRESH_SECRET")
accessTTL := time.Minute * 15
refreshTTL := time.Hour * 24 * 7
jwtManager := auth.NewJWTManager(accessSecret, refreshSecret, accessTTL, refreshTTL)
client := // connect to mongo
db := client.Database("yourdb")
userStore := store.NewUserStore(db, "users")
tokenStore := store.NewTokenStore(db, "refresh_tokens")
authHandler := handlers.AuthHandler{
JWT: jwtManager,
Users: userStore,
Tokens: tokenStore,
}
r := gin.Default()
api := r.Group("/api")
api.POST("/register", authHandler.Register)
api.POST("/login", authHandler.Login)
api.POST("/refresh", authHandler.Refresh)
api.POST("/logout", authHandler.Logout)
// protected
protected := api.Group("/me")
protected.Use(middleware.AuthRequired(jwtManager))
protected.GET("/", func(c *gin.Context) {
uid, _ := c.Get("userID")
c.JSON(200, gin.H{"user_id": uid})
})
r.Run(":8080")
}- Use HTTPS always. Never send tokens over plain HTTP.
- HttpOnly & Secure cookies for refresh tokens reduce XSS risk. If we do cookies, protect against CSRF (use double submit cookie or SameSite=strict/lax depending on UX).
- Rotate secrets & support secret rotation: keep short access secret lifetime, rotate refresh secret carefully (maintain old secret for a short overlap if needed).
- Least privilege DB user: limit Mongo user to only required collections and actions.
- Short-lived access tokens (e.g., 5–30 minutes).
- Store refresh tokens server-side if we need to be able to revoke sessions (logout, password change).
- Monitor suspicious token usage (multiple refresh token uses from different IPs — possible token theft). Consider storing
ip,userAgentinRefreshTokenRecord. - Limit concurrent sessions if business requires — e.g., restrict number of active refresh tokens per user.
- Hash refresh tokens in DB (optional): to avoid DB compromise exposing raw refresh tokens, store a hashed version (like bcrypt or HMAC) and compare hash of presented token. That prevents attackers with DB read-only access from using tokens.
- Unit test token generation/verification with known secrets and simulated time (use
jwt.WithLeewayor setIssuedAt/ExpiresAtmanually). - Integration test login/refresh flows with a real Mongo instance (testcontainers or local mongo in CI).
- Test security edge-cases: reuse of revoked refresh token should be rejected. Test refresh token rotation.
- Stateless refresh tokens — we can sign refresh tokens but still keep a revocation list in DB for logout.
- Use asymmetric keys (RS256) — allows key rotation and separation of signing vs verification if we have multiple services. Keep private key safe.
- Use OAuth2/JWT libraries (if we need more features): consider full OAuth2 for third-party auth.
- Rate-limit login endpoint (prevent credential stuffing).