writing/tutorial/2026/03
TutorialMar 2, 2026·30 min read

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

Learn how to build fast, production-ready REST APIs using Go and the Fiber web framework. This step-by-step guide covers project setup, routing, JSON handling, database integration with GORM, middleware, error handling, and testing — from zero to a working API.

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:

FeatureFiberGinEchoChi
Inspired byExpress.jsMartiniSinatrastdlib
HTTP engineFasthttpnet/httpnet/httpnet/http
PerformanceFastestFastFastFast
Learning curveLow (Express-like)LowModerateLow
MiddlewareBuilt-in + customCommunityBuilt-instdlib 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-api

Install 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/godotenv

Create the project structure:

mkdir -p cmd/api internal/{models,handlers,database,middleware,config}
touch cmd/api/main.go
touch .env

Your 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=3000

Create 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.go

You 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/tasks

Update 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/1

Step 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/ -v

Step 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 kill

Performance Comparison

Here's how Fiber compares to popular frameworks across languages:

FrameworkLanguageReq/sec (hello world)Memory
FiberGo~500,000~10 MB
GinGo~350,000~12 MB
ExpressNode.js~40,000~80 MB
FastAPIPython~25,000~50 MB
AxumRust~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.