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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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.


Want to read more tutorials? Check out our latest tutorial on Building AI Applications with Google Gemini API and TypeScript.

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

30 min read·