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.