Building REST APIs with Gin & MongoDB
Learn how to build production-ready REST APIs in Go using the Gin web framework and MongoDB for data persistence. This section covers HTTP methods, routing, middleware, validation, error handling, and real database operations with a complete example application.
What is REST?
REST stands for Representational State Transfer. It's an architectural style for building distributed systems, particularly web APIs. REST APIs use HTTP requests to perform CRUD (Create, Read, Update, Delete) operations on resources.
REST APIs are stateless — each request contains all the information needed
to process it. They're resource-based — you interact with resources (like
/todos or /users/123) using standard HTTP methods.
1. Resource-based: Everything is a resource identified by a URI (e.g., /todos, /todos/123).
2. HTTP methods: Use GET, POST, PUT, PATCH, DELETE for CRUD operations.
3. Statelessness: Each request is independent; the server doesn't store client state.
4. Uniform interface: Consistent patterns for requests and responses.
5. Self-descriptive messages: Requests include metadata (headers) and data (body).
HTTP Methods Explained
REST APIs use standard HTTP methods to perform operations. Each method has a specific purpose and semantic meaning.
Understanding these differences is crucial for building correct REST APIs:
- GET retrieves data. Safe (no side effects) and idempotent (multiple calls return the same result).
- POST creates a new resource. Not safe (creates data) and not idempotent (multiple calls create multiple resources).
- PUT replaces a resource entirely. Not safe but idempotent (replacing the same data multiple times has the same effect).
- PATCH updates part of a resource. Not safe and not idempotent (unless designed to be).
- DELETE removes a resource. Not safe but idempotent (deleting an already-deleted resource returns 404, which is idempotent).
Project Setup
Start by initializing a new Go module and installing the required dependencies. Gin is a lightweight web framework, and the MongoDB driver provides native Go support for database operations.
These three packages form the foundation: Gin for HTTP routing and middleware, MongoDB driver for database access, and validator for input validation.
Data Models
Define your data structures using Go structs with struct tags for JSON serialization and validation. The tags tell Gin how to map JSON fields and validate incoming data.
package main
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Todo struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
Title string `bson:"title" json:"title" binding:"required,min=1,max=200"`
Completed bool `bson:"completed" json:"completed"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type CreateTodoRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
}
type UpdateTodoRequest struct {
Title string `json:"title" binding:"omitempty,min=1,max=200"`
Completed bool `json:"completed"`
}
The binding tags define validation rules: required means the field
must be present, min=1 and max=200 enforce string length constraints.
The bson tags map to MongoDB field names.
Routing & Handlers
Gin uses a simple router that maps HTTP methods and paths to handler functions. Each handler receives a context object that provides access to the request, response, and database connection.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"context"
"time"
)
func CreateTodo(c *gin.Context) {
var req CreateTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
todo := Todo{
Title: req.Title,
Completed: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := todosCollection.InsertOne(ctx, todo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create todo"})
return
}
todo.ID = result.InsertedID.(primitive.ObjectID)
c.JSON(http.StatusCreated, todo)
}
func GetTodos(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := todosCollection.Find(ctx, bson.M{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch todos"})
return
}
defer cursor.Close(ctx)
var todos []Todo
if err = cursor.All(ctx, &todos); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse todos"})
return
}
c.JSON(http.StatusOK, todos)
}
func GetTodoByID(c *gin.Context) {
id := c.Param("id")
objID, err := primitive.ObjectIDFromHex(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
todo := Todo{}
err = todosCollection.FindOne(ctx, bson.M{"_id": objID}).Decode(&todo)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
return
}
c.JSON(http.StatusOK, todo)
}
func UpdateTodo(c *gin.Context) {
id := c.Param("id")
objID, err := primitive.ObjectIDFromHex(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
var req UpdateTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
update := bson.M{
"$set": bson.M{
"updated_at": time.Now(),
},
}
if req.Title != "" {
update["$set"].(bson.M)["title"] = req.Title
}
update["$set"].(bson.M)["completed"] = req.Completed
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := todosCollection.UpdateOne(ctx, bson.M{"_id": objID}, update)
if err != nil || result.MatchedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Todo updated"})
}
func DeleteTodo(c *gin.Context) {
id := c.Param("id")
objID, err := primitive.ObjectIDFromHex(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := todosCollection.DeleteOne(ctx, bson.M{"_id": objID})
if err != nil || result.DeletedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Todo deleted"})
}
Each handler follows the same pattern: validate input with ShouldBindJSON,
perform the database operation with a timeout context, and return appropriate HTTP status codes.
The defer cancel() ensures the context is always cleaned up.
Middleware
Middleware functions run before or after handlers. Common uses include logging, authentication, CORS headers, and error recovery.
package main
import (
"github.com/gin-gonic/gin"
"log"
"time"
)
func LoggingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
log.Printf("%s %s %d %v", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), duration)
}
}
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
Validation
Gin integrates with the validator package to automatically validate struct fields based on tags. Custom validators can be registered for domain-specific rules.
package main
import (
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"context"
"log"
)
var todosCollection *mongo.Collection
func init() {
client, err := mongo.Connect(context.Background(), options.Client().ApplyURI("mongodb://localhost:27017"))
if err != nil {
log.Fatal(err)
}
todosCollection = client.Database("todo_db").Collection("todos")
}
func main() {
router := gin.Default()
// Apply middleware
router.Use(LoggingMiddleware())
router.Use(CORSMiddleware())
// Routes
router.POST("/todos", CreateTodo)
router.GET("/todos", GetTodos)
router.GET("/todos/:id", GetTodoByID)
router.PUT("/todos/:id", UpdateTodo)
router.PATCH("/todos/:id", UpdateTodo)
router.DELETE("/todos/:id", DeleteTodo)
// Start server
router.Run(":8080")
}
The complete route table shows all CRUD operations. Note that both PUT and PATCH use the same handler in this example — in a real app, you might separate them for stricter validation.
Error Handling
Proper error handling is critical for APIs. Always return appropriate HTTP status codes and meaningful error messages. Use context timeouts to prevent hanging requests.
Use appropriate status codes: 200 for success, 201 for created, 400 for bad request,
404 for not found, 500 for server error.
Validate early: Check input before database operations.
Use timeouts: Always set context timeouts for database operations to prevent hanging.
Log everything: Use middleware to log requests, responses, and errors for debugging.
Be consistent: Use the same error format across all endpoints.
Interview Questions
Common interview questions about designing and building REST APIs in Go — organized by experience level. Click a tab to filter.
Q1. What is REST?
REST (Representational State Transfer) is an architectural style for networked APIs built around resources identified by URLs, manipulated through a small set of uniform verbs (GET, POST, PUT, PATCH, DELETE), and exchanged as representations such as JSON. It favors statelessness, a uniform interface, and layered, cacheable communication over the transport (almost always HTTP).
Q2. What does it mean for a REST API to be "stateless"?
Statelessness means each request contains all the information the server needs to process it — the server does not rely on data stored from a previous request (like server-side session state). This is why REST APIs authenticate every request independently, typically with a token or credential sent in a header rather than a cookie-backed session.
Q3. What is a "resource" in REST?
A resource is any named entity an API exposes — a user, an order, a document — identified by a URL such as /users/42. Collections of resources (/users) and individual resources (/users/42) are the nouns that HTTP methods act on as verbs.
Q4. What does GET do, and can it have a request body?
GET retrieves a representation of a resource without modifying server state. While HTTP technically permits a body on a GET request, net/http and most servers/clients ignore or discourage it, so query parameters (r.URL.Query()) are the conventional way to pass input.
Q5. What does POST do?
POST submits data to create a new resource or trigger a server-side action, typically under a collection URL like /users. The server usually responds with 201 Created and a Location header pointing to the new resource.
Q6. What's the difference between PUT and PATCH?
PUT replaces an entire resource with the representation supplied in the request body, while PATCH applies a partial update, modifying only the fields provided. PUT to a full URL like /users/42 is expected to fully overwrite that resource's state.
Q7. What does DELETE do?
DELETE removes the resource identified by the URL. A successful delete commonly returns 204 No Content with an empty body, or 200 OK if the response includes a representation of the deleted resource.
Q8. How do you define a route with net/http's ServeMux?
You create a mux with http.NewServeMux() and register patterns with mux.HandleFunc("GET /users/{id}", handler), then pass the mux as the Handler to http.ListenAndServe. Since Go 1.22, patterns can include an HTTP method prefix and {name} wildcards.
Q9. What is the difference between using http.HandleFunc and implementing http.Handler directly?
http.HandleFunc registers a plain function matching func(http.ResponseWriter, *http.Request), which Go adapts via the http.HandlerFunc type. Implementing http.Handler directly means defining a type with a ServeHTTP(w http.ResponseWriter, r *http.Request) method, which is useful when the handler needs to carry its own state, such as a database connection.
Q1. Why should PUT be idempotent but POST isn't required to be?
Idempotent means making the same request multiple times produces the same server state as making it once. PUT replaces a resource wholesale, so repeating it with identical data leaves the resource unchanged after the first call, making retries safe. POST typically creates a new resource each time, so repeating it (e.g. after a client timeout) can create duplicates unless the API adds extra protection like an idempotency key.
Q2. What's a common gotcha with Go's json struct tags?
encoding/json only marshals and unmarshals exported (capitalized) fields, so an unexported field is silently skipped with no error. Another frequent trap is omitempty on value types: a zero value like 0, "", or false is omitted from output even when it was an intentional value, which is why optional fields are often modeled as pointers or explicit types.
Q3. Why validate input before touching business logic?
Validating early — checking required fields, formats, and ranges right after decoding the request — lets you reject malformed input with a clear 400 Bad Request before it reaches database calls or domain logic. This avoids partial writes, confusing downstream errors, and security issues from unchecked data reaching business rules that assume well-formed input.
Q4. What is middleware in a Go HTTP server, and how do you chain it?
Middleware is a function that wraps an http.Handler, returning a new http.Handler that runs logic before and/or after calling the wrapped one — the common signature is func(http.Handler) http.Handler. Chaining is just nested function calls, e.g. logging(auth(mux)), so requests flow through each layer in order before reaching the final handler.
Q5. Why does middleware ordering matter, such as logging versus auth?
If logging wraps auth (logging(auth(handler))), every request is logged including ones auth later rejects, which is usually what you want for audit trails. If auth wraps logging instead, rejected requests never reach the logging layer, so ordering directly determines what gets observed and what gets short-circuited before reaching the handler.
Q6. Why must you call w.WriteHeader before writing the response body?
http.ResponseWriter sends the status line and headers as soon as the first byte is written to the body via Write, defaulting to 200 OK if WriteHeader hasn't been called yet. Calling WriteHeader after any Write has no effect and only logs a warning, so a non-200 status must always be set before the body is written.
Q7. Why is limiting request body size important?
Without a limit, a client can send an arbitrarily large body and exhaust server memory or bandwidth while your handler is still decoding it. http.MaxBytesReader(w, r.Body, n) wraps the body so reads past the limit fail with an error, letting you reject oversized payloads early with 413 Request Entity Too Large.
Q8. Why return structured JSON error responses instead of plain text?
A structured error body (e.g. {"error": "invalid email", "code": "VALIDATION_ERROR"}) lets clients programmatically branch on the failure instead of parsing human-readable strings. It also keeps the response Content-Type consistent with successful responses, which simplifies client-side deserialization.
Q9. What's the difference between 401 Unauthorized and 403 Forbidden?
401 means the request lacks valid authentication credentials entirely — the client should log in or supply a token. 403 means the credentials were recognized but the authenticated identity isn't permitted to perform the action, so re-authenticating won't help.
Q1. What are the tradeoffs between common API versioning strategies?
URI versioning (/v1/users) is explicit and cache-friendly but clutters URLs and encourages breaking changes to live under new paths. Header-based versioning (a custom header or Accept media-type parameter) keeps URLs stable and fits content negotiation better, but is less visible and harder to test by simply pasting a URL in a browser. Most teams pick URI versioning for simplicity and reserve header negotiation for APIs with strict hypermedia/content-type discipline.
Q2. Offset pagination versus cursor pagination — when would you choose each?
Offset pagination (?page=3&limit=20) is simple to implement and lets clients jump to arbitrary pages, but it can skip or duplicate rows if the underlying data changes between requests, and performance degrades on large offsets. Cursor pagination (?after=<opaque-id>) encodes a stable position (often the last row's sort key) and stays consistent under concurrent writes, making it the better choice for large, frequently-mutated datasets or infinite-scroll style APIs.
Q3. How would you implement rate limiting in a Go HTTP API?
A common approach is a token-bucket limiter per client key (API key, user ID, or IP), such as golang.org/x/time/rate, applied inside middleware before the handler runs. For multi-instance deployments the bucket state needs to live in a shared store like Redis rather than in-process memory, and rejected requests should return 429 Too Many Requests with a Retry-After header.
Q4. How does context propagate through a middleware chain, and why does it matter?
Each middleware can derive a new context from r.Context() via context.WithValue, context.WithTimeout, or context.WithCancel, then call the next handler with r.WithContext(ctx) so the enriched context flows downstream. This is how request-scoped data (auth claims, trace IDs) and cancellation signals (client disconnects, deadlines) reach handlers and any database or downstream HTTP calls made with that context.
Q5. When would you use net/http's built-in ServeMux pattern matching versus a third-party router like chi or gorilla/mux?
Since Go 1.22, http.ServeMux supports method-prefixed patterns and {name} wildcards (read via r.PathValue("name")), which covers most straightforward REST routing without a dependency. Third-party routers still add value for features the standard library omits, like route groups with shared middleware, regex constraints on path segments, or built-in sub-router mounting, which matter more as route trees grow large.
Q6. How do you implement graceful shutdown for a Go HTTP server?
Run http.Server.ListenAndServe in a goroutine, then block the main goroutine on an OS signal (SIGINT/SIGTERM) via signal.Notify. On signal, call srv.Shutdown(ctx) with a bounded-timeout context, which stops accepting new connections and waits for in-flight requests to finish before returning, instead of dropping active clients abruptly.
Q7. What is an idempotency key and why would you add one to a POST endpoint?
An idempotency key is a client-generated unique value (often sent in an Idempotency-Key header) that the server records alongside the result of a POST operation. If the same key arrives again — typically from a client retry after a timeout — the server returns the stored result instead of re-executing the action, preventing duplicate resource creation from network-level retries.
Q8. What is HATEOAS, and what's the tradeoff of adopting it?
HATEOAS (Hypermedia as the Engine of Application State) means responses include links describing available next actions, so clients discover the API dynamically rather than hardcoding URLs. It decouples clients from URL structure and can ease evolution, but it adds response payload size and client complexity that most internal or mobile-backend APIs don't need, which is why it's far less common in practice than plain JSON REST.
Q9. How should an API represent partial failure in a bulk operation, e.g. a batch create endpoint?
A single top-level status code can't represent "some items succeeded, some failed," so bulk endpoints typically return 207 Multi-Status (or 200/201 with a per-item result array) where each item carries its own status and error detail. This lets the client identify exactly which entries need to be retried without treating the entire batch as one atomic success or failure.