Building REST APIs with Go and Fiber: A Practical Beginner's Guide

Go is the language behind Docker, Kubernetes, and most cloud-native infrastructure — and Fiber makes it accessible to web developers. Inspired by Express.js and built on Fasthttp, Fiber delivers up to 10x the throughput of Node.js with memory safety and goroutine-based concurrency. In this tutorial, you'll build a complete REST API from scratch.
What You'll Build
A fully functional Task Manager API with:
- CRUD operations for tasks (create, read, update, delete)
- JSON request/response handling
- PostgreSQL database with GORM (Go's most popular ORM)
- Structured error handling with proper HTTP status codes
- Middleware (logging, CORS, rate limiting)
- Request validation
- Environment-based configuration
- Integration tests
Prerequisites
Before starting, make sure you have:
- Go 1.22+ installed (go.dev/dl)
- PostgreSQL 15+ running locally or via Docker
- Basic understanding of HTTP and REST concepts
- A code editor (VS Code with the Go extension recommended)
- A terminal
New to Go? You should be comfortable with structs, interfaces, error handling, and goroutines. The official Tour of Go is a great place to start.
Why Fiber?
Before diving in, let's understand why Fiber stands out in the Go ecosystem:
| Feature | Fiber | Gin | Echo | Chi |
|---|---|---|---|---|
| Inspired by | Express.js | Martini | Sinatra | stdlib |
| HTTP engine | Fasthttp | net/http | net/http | net/http |
| Performance | Fastest | Fast | Fast | Fast |
| Learning curve | Low (Express-like) | Low | Moderate | Low |
| Middleware | Built-in + custom | Community | Built-in | stdlib compatible |
Fiber's Express-like API means if you've built Node.js servers, you'll feel right at home — but with Go's performance and type safety.
Step 1: Project Setup
Create your project directory and initialize the Go module:
mkdir task-api && cd task-api
go mod init github.com/yourusername/task-apiInstall the core dependencies:
go get github.com/gofiber/fiber/v2
go get gorm.io/gorm
go get gorm.io/driver/postgres
go get github.com/joho/godotenvCreate the project structure:
mkdir -p cmd/api internal/{models,handlers,database,middleware,config}
touch cmd/api/main.go
touch .envYour project should look like this:
task-api/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── database/
│ │ └── database.go
│ ├── handlers/
│ │ └── task.go
│ ├── middleware/
│ │ └── middleware.go
│ └── models/
│ └── task.go
├── .env
├── go.mod
└── go.sum
Step 2: Environment Configuration
Set up your .env file:
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=taskapi
APP_PORT=3000Create the configuration loader in internal/config/config.go:
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
type Config struct {
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
AppPort string
}
func LoadConfig() *Config {
err := godotenv.Load()
if err != nil {
log.Println("No .env file found, using system environment variables")
}
return &Config{
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "postgres"),
DBPassword: getEnv("DB_PASSWORD", "postgres"),
DBName: getEnv("DB_NAME", "taskapi"),
AppPort: getEnv("APP_PORT", "3000"),
}
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}Step 3: Define the Data Model
Create the Task model in internal/models/task.go:
package models
import (
"time"
"gorm.io/gorm"
)
type TaskStatus string
const (
StatusPending TaskStatus = "pending"
StatusInProgress TaskStatus = "in_progress"
StatusCompleted TaskStatus = "completed"
)
type Task struct {
ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"size:255;not null"`
Description string `json:"description" gorm:"type:text"`
Status TaskStatus `json:"status" gorm:"size:20;default:pending"`
Priority int `json:"priority" gorm:"default:0"`
DueDate *time.Time `json:"due_date,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
type CreateTaskRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Priority int `json:"priority"`
DueDate *time.Time `json:"due_date"`
}
type UpdateTaskRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *TaskStatus `json:"status"`
Priority *int `json:"priority"`
DueDate *time.Time `json:"due_date"`
}
func (r *CreateTaskRequest) Validate() map[string]string {
errors := make(map[string]string)
if r.Title == "" {
errors["title"] = "Title is required"
}
if len(r.Title) > 255 {
errors["title"] = "Title must be 255 characters or less"
}
if r.Priority < 0 || r.Priority > 5 {
errors["priority"] = "Priority must be between 0 and 5"
}
return errors
}Notice how we use gorm.DeletedAt for soft deletes — records won't be permanently removed from the database, making it easy to restore them later.
Step 4: Database Connection
Set up the database layer in internal/database/database.go:
package database
import (
"fmt"
"log"
"github.com/yourusername/task-api/internal/config"
"github.com/yourusername/task-api/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
func Connect(cfg *config.Config) {
dsn := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName,
)
var err error
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
log.Println("Database connected successfully")
// Auto-migrate the schema
err = DB.AutoMigrate(&models.Task{})
if err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
log.Println("Database migrated successfully")
}Create the PostgreSQL database before running the app:
createdb taskapi
# Or using psql:
# psql -U postgres -c "CREATE DATABASE taskapi;"Step 5: Build the API Handlers
This is the core of your API. Create internal/handlers/task.go:
package handlers
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/yourusername/task-api/internal/database"
"github.com/yourusername/task-api/internal/models"
)
// ListTasks returns all tasks with optional filtering
func ListTasks(c *fiber.Ctx) error {
var tasks []models.Task
query := database.DB
// Filter by status if provided
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
// Filter by priority if provided
if priority := c.Query("priority"); priority != "" {
query = query.Where("priority = ?", priority)
}
// Pagination
page, _ := strconv.Atoi(c.Query("page", "1"))
limit, _ := strconv.Atoi(c.Query("limit", "10"))
offset := (page - 1) * limit
var total int64
query.Model(&models.Task{}).Count(&total)
result := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&tasks)
if result.Error != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to fetch tasks",
})
}
return c.JSON(fiber.Map{
"data": tasks,
"total": total,
"page": page,
"limit": limit,
})
}
// GetTask returns a single task by ID
func GetTask(c *fiber.Ctx) error {
id := c.Params("id")
var task models.Task
result := database.DB.First(&task, id)
if result.Error != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "Task not found",
})
}
return c.JSON(task)
}
// CreateTask creates a new task
func CreateTask(c *fiber.Ctx) error {
req := new(models.CreateTaskRequest)
if err := c.BodyParser(req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid request body",
})
}
// Validate
if errors := req.Validate(); len(errors) > 0 {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"errors": errors,
})
}
task := models.Task{
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
DueDate: req.DueDate,
Status: models.StatusPending,
}
result := database.DB.Create(&task)
if result.Error != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to create task",
})
}
return c.Status(fiber.StatusCreated).JSON(task)
}
// UpdateTask updates an existing task
func UpdateTask(c *fiber.Ctx) error {
id := c.Params("id")
var task models.Task
if err := database.DB.First(&task, id).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "Task not found",
})
}
req := new(models.UpdateTaskRequest)
if err := c.BodyParser(req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid request body",
})
}
// Apply partial updates
if req.Title != nil {
task.Title = *req.Title
}
if req.Description != nil {
task.Description = *req.Description
}
if req.Status != nil {
task.Status = *req.Status
}
if req.Priority != nil {
task.Priority = *req.Priority
}
if req.DueDate != nil {
task.DueDate = req.DueDate
}
database.DB.Save(&task)
return c.JSON(task)
}
// DeleteTask soft-deletes a task
func DeleteTask(c *fiber.Ctx) error {
id := c.Params("id")
var task models.Task
if err := database.DB.First(&task, id).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "Task not found",
})
}
database.DB.Delete(&task)
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Task deleted successfully",
})
}Step 6: Add Middleware
Create internal/middleware/middleware.go for logging, CORS, and rate limiting:
package middleware
import (
"log"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
)
func SetupMiddleware(app *fiber.App) {
// CORS
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowMethods: "GET,POST,PUT,PATCH,DELETE",
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
}))
// Rate limiting: 100 requests per minute
app.Use(limiter.New(limiter.Config{
Max: 100,
Expiration: 1 * time.Minute,
LimiterMiddleware: limiter.SlidingWindow{},
}))
// Request logging
app.Use(func(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
duration := time.Since(start)
log.Printf("%s %s %d %s",
c.Method(),
c.Path(),
c.Response().StatusCode(),
duration,
)
return err
})
}Step 7: Wire Everything Together
Create the entry point in cmd/api/main.go:
package main
import (
"fmt"
"log"
"github.com/gofiber/fiber/v2"
"github.com/yourusername/task-api/internal/config"
"github.com/yourusername/task-api/internal/database"
"github.com/yourusername/task-api/internal/handlers"
"github.com/yourusername/task-api/internal/middleware"
)
func main() {
// Load configuration
cfg := config.LoadConfig()
// Connect to database
database.Connect(cfg)
// Create Fiber app
app := fiber.New(fiber.Config{
AppName: "Task API v1.0",
ErrorHandler: customErrorHandler,
})
// Setup middleware
middleware.SetupMiddleware(app)
// Health check
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"service": "task-api",
})
})
// API v1 routes
v1 := app.Group("/api/v1")
{
tasks := v1.Group("/tasks")
tasks.Get("/", handlers.ListTasks)
tasks.Get("/:id", handlers.GetTask)
tasks.Post("/", handlers.CreateTask)
tasks.Put("/:id", handlers.UpdateTask)
tasks.Delete("/:id", handlers.DeleteTask)
}
// Start server
addr := fmt.Sprintf(":%s", cfg.AppPort)
log.Printf("Server starting on %s", addr)
log.Fatal(app.Listen(addr))
}
func customErrorHandler(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).JSON(fiber.Map{
"error": err.Error(),
})
}Step 8: Run and Test Your API
Start the server:
go run cmd/api/main.goYou should see:
Database connected successfully
Database migrated successfully
Server starting on :3000
Now test with curl:
Create a task:
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "Learn Go Fiber",
"description": "Complete the REST API tutorial",
"priority": 3
}'Response:
{
"id": 1,
"title": "Learn Go Fiber",
"description": "Complete the REST API tutorial",
"status": "pending",
"priority": 3,
"created_at": "2026-03-02T10:00:00Z",
"updated_at": "2026-03-02T10:00:00Z"
}List all tasks:
curl http://localhost:3000/api/v1/tasksUpdate a task:
curl -X PUT http://localhost:3000/api/v1/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'Filter by status:
curl "http://localhost:3000/api/v1/tasks?status=pending&page=1&limit=5"Delete a task:
curl -X DELETE http://localhost:3000/api/v1/tasks/1Step 9: Write Integration Tests
Create cmd/api/main_test.go:
package main
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/yourusername/task-api/internal/handlers"
"github.com/yourusername/task-api/internal/models"
)
func setupTestApp() *fiber.App {
app := fiber.New()
v1 := app.Group("/api/v1")
tasks := v1.Group("/tasks")
tasks.Get("/", handlers.ListTasks)
tasks.Get("/:id", handlers.GetTask)
tasks.Post("/", handlers.CreateTask)
tasks.Put("/:id", handlers.UpdateTask)
tasks.Delete("/:id", handlers.DeleteTask)
return app
}
func TestCreateTask(t *testing.T) {
app := setupTestApp()
task := models.CreateTaskRequest{
Title: "Test Task",
Description: "A task for testing",
Priority: 2,
}
body, _ := json.Marshal(task)
req := httptest.NewRequest("POST", "/api/v1/tasks", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("Failed to make request: %v", err)
}
if resp.StatusCode != fiber.StatusCreated {
t.Errorf("Expected status 201, got %d", resp.StatusCode)
}
}
func TestCreateTaskValidation(t *testing.T) {
app := setupTestApp()
// Empty title should fail
task := models.CreateTaskRequest{
Title: "",
}
body, _ := json.Marshal(task)
req := httptest.NewRequest("POST", "/api/v1/tasks", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("Failed to make request: %v", err)
}
if resp.StatusCode != fiber.StatusUnprocessableEntity {
t.Errorf("Expected status 422, got %d", resp.StatusCode)
}
}
func TestGetNonExistentTask(t *testing.T) {
app := setupTestApp()
req := httptest.NewRequest("GET", "/api/v1/tasks/99999", nil)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("Failed to make request: %v", err)
}
if resp.StatusCode != fiber.StatusNotFound {
t.Errorf("Expected status 404, got %d", resp.StatusCode)
}
}Run the tests:
go test ./cmd/api/ -vStep 10: Add Graceful Shutdown
Update cmd/api/main.go to handle graceful shutdown:
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/gofiber/fiber/v2"
"github.com/yourusername/task-api/internal/config"
"github.com/yourusername/task-api/internal/database"
"github.com/yourusername/task-api/internal/handlers"
"github.com/yourusername/task-api/internal/middleware"
)
func main() {
cfg := config.LoadConfig()
database.Connect(cfg)
app := fiber.New(fiber.Config{
AppName: "Task API v1.0",
ErrorHandler: customErrorHandler,
})
middleware.SetupMiddleware(app)
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"service": "task-api",
})
})
v1 := app.Group("/api/v1")
{
tasks := v1.Group("/tasks")
tasks.Get("/", handlers.ListTasks)
tasks.Get("/:id", handlers.GetTask)
tasks.Post("/", handlers.CreateTask)
tasks.Put("/:id", handlers.UpdateTask)
tasks.Delete("/:id", handlers.DeleteTask)
}
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-quit
log.Println("Shutting down server...")
if err := app.Shutdown(); err != nil {
log.Fatalf("Server shutdown failed: %v", err)
}
}()
addr := fmt.Sprintf(":%s", cfg.AppPort)
log.Printf("Server starting on %s", addr)
log.Fatal(app.Listen(addr))
}
func customErrorHandler(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).JSON(fiber.Map{
"error": err.Error(),
})
}Troubleshooting
"connection refused" when connecting to PostgreSQL:
Make sure PostgreSQL is running and the credentials in .env match. If using Docker:
docker run --name taskdb -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:15
docker exec -it taskdb psql -U postgres -c "CREATE DATABASE taskapi;""go: module not found" errors:
Run go mod tidy to resolve dependency issues.
Port already in use:
Change APP_PORT in .env or kill the process using the port:
lsof -ti:3000 | xargs killPerformance Comparison
Here's how Fiber compares to popular frameworks across languages:
| Framework | Language | Req/sec (hello world) | Memory |
|---|---|---|---|
| Fiber | Go | ~500,000 | ~10 MB |
| Gin | Go | ~350,000 | ~12 MB |
| Express | Node.js | ~40,000 | ~80 MB |
| FastAPI | Python | ~25,000 | ~50 MB |
| Axum | Rust | ~550,000 | ~8 MB |
Benchmarks vary by hardware. Source: TechEmpower Framework Benchmarks.
Next Steps
- Add JWT authentication using
github.com/gofiber/jwt/v3 - Add Swagger documentation with
github.com/gofiber/swagger - Deploy with Docker — create a multi-stage Dockerfile for minimal image size
- Add WebSocket support for real-time task updates
- Implement caching with Redis using
github.com/gofiber/storage/redis
Conclusion
You've built a complete REST API with Go and Fiber that includes:
- Clean project structure following Go best practices
- Full CRUD operations with PostgreSQL and GORM
- Input validation and structured error handling
- Middleware for CORS, rate limiting, and logging
- Graceful shutdown for production readiness
- Integration tests using Fiber's built-in test utilities
Go and Fiber give you the performance of a compiled language with the developer experience of Express.js. The resulting binary is a single, self-contained executable that starts in milliseconds and handles thousands of concurrent connections with minimal memory.
For your next project, consider adding authentication, WebSocket support, or deploying to a cloud platform with Docker. The foundation you've built here scales from side projects to production workloads serving millions of requests.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Building Production-Ready REST APIs with FastAPI, PostgreSQL, and Docker
Learn how to build, test, and deploy a production-grade REST API using Python's FastAPI framework with PostgreSQL, SQLAlchemy, Alembic migrations, and Docker Compose — from zero to deployment.

Building REST APIs with Rust and Axum: A Practical Beginner's Guide
Learn how to build fast, safe REST APIs using Rust and the Axum web framework. This step-by-step guide covers project setup, routing, JSON handling, database integration with SQLx, error handling, and testing — from zero to a working API.

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.