JSON in Fiber: c.JSON(), BodyParser, fiber.Map & Input Validation

Last updated:

Fiber returns JSON from any handler with c.JSON(data) — passing a Go struct, fiber.Map, or slice produces a Content-Type: application/json response using encoding/json, with optional sonic integration for 2–3× faster serialization. c.BodyParser(&target) reads and decodes the JSON request body into a Go struct; it returns a Fiber error if the Content-Type is wrong or the body is malformed. Fiber is built on fasthttp rather than Go's standard net/http, reaching 1.6 million requests/second in benchmarks. fiber.Map{"status": "ok"} creates an inline JSON response without defining a struct — a type alias for map[string]interface{"{}"}. c.Status(fiber.StatusCreated).JSON(data) combines a custom HTTP status code with a JSON body. This guide covers c.JSON(), BodyParser, fiber.Map, input validation with go-playground/validator, JSON error responses, middleware, and configuring Fiber's JSON encoder.

c.JSON() and fiber.Map: Returning JSON Responses

c.JSON(data) is Fiber's primary JSON response method. It accepts any Go value, serializes it with the configured JSON encoder (default: encoding/json), sets the Content-Type: application/json header, and sends an HTTP 200 response. Pass a named struct for typed API contracts or fiber.Map for quick inline responses. The return value of c.JSON() is an error — always return it from the handler so Fiber can route encoding failures to your error handler.

package main

import (
	"github.com/gofiber/fiber/v2"
)

// ── Named struct — preferred for public API contracts ──────────
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

// ── fiber.Map — type alias for map[string]interface{} ──────────
// Idiomatic for quick inline responses without a struct definition
// fiber.Map{"key": "value"} == map[string]interface{}{"key": "value"}

func main() {
	app := fiber.New()

	// ── Return a struct as JSON (HTTP 200) ─────────────────────
	app.Get("/users/:id", func(c *fiber.Ctx) error {
		user := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
		return c.JSON(user)
		// → {"id":1,"name":"Alice","email":"alice@example.com"}
	})

	// ── Return fiber.Map for simple responses ──────────────────
	app.Get("/health", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{
			"status":  "ok",
			"version": "1.0.0",
		})
		// → {"status":"ok","version":"1.0.0"}
	})

	// ── Return a JSON array (slice of structs) ─────────────────
	app.Get("/users", func(c *fiber.Ctx) error {
		users := []User{
			{ID: 1, Name: "Alice", Email: "alice@example.com"},
			{ID: 2, Name: "Bob",   Email: "bob@example.com"},
		}
		return c.JSON(users)
		// → [{"id":1,...},{"id":2,...}]
	})

	// ── Return an empty array (NOT null) ──────────────────────
	// var items []User  ← serializes to null — avoid this
	app.Get("/empty", func(c *fiber.Ctx) error {
		items := make([]User, 0) // serializes to []
		return c.JSON(items)
	})

	// ── Nested fiber.Map ───────────────────────────────────────
	app.Get("/nested", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{
			"user": fiber.Map{
				"id":   42,
				"name": "Alice",
			},
			"meta": fiber.Map{
				"requestId": "abc-123",
			},
		})
	})

	app.Listen(":3000")
}

JSON struct tags control how field names appear in the output. json:"id" maps the Go field ID to the JSON key "id". Add omitempty to skip zero-value fields: json:"bio,omitempty" omits the bio key entirely when the string is empty, rather than outputting "bio":"". Use json:"-" to permanently exclude a field (passwords, internal state) from all JSON output. For fields you want to exclude only sometimes, use a pointer and omitempty — a nil pointer with omitempty is skipped.

c.BodyParser(): Reading JSON Request Bodies

c.BodyParser(&target) reads the raw request body, detects the Content-Type header, and deserializes the content into the target pointer. For JSON requests (Content-Type: application/json), it delegates to the configured JSON decoder — encoding/json by default. Call BodyParser at the very top of POST and PUT handlers, before any business logic, and handle errors immediately. An error means the client sent malformed JSON, an unsupported content type, or a body that exceeds the configured size limit.

package main

import (
	"github.com/gofiber/fiber/v2"
)

// ── Input struct with json tags ────────────────────────────────
type CreateUserInput struct {
	Name  string `json:"name"`
	Email string `json:"email"`
	Age   int    `json:"age"`
}

func main() {
	app := fiber.New()

	// ── Basic BodyParser usage ─────────────────────────────────
	app.Post("/users", func(c *fiber.Ctx) error {
		var input CreateUserInput

		// BodyParser returns error on:
		//   - malformed JSON ("unexpected end of JSON input")
		//   - wrong Content-Type (returns fiber.ErrUnprocessableEntity)
		//   - body too large (> BodyLimit in fiber.Config)
		if err := c.BodyParser(&input); err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": "invalid request body",
				"detail": err.Error(),
			})
		}

		// input is now populated from the JSON body
		// {"name":"Alice","email":"alice@example.com","age":30}
		return c.Status(fiber.StatusCreated).JSON(fiber.Map{
			"id":    1,
			"name":  input.Name,
			"email": input.Email,
		})
	})

	// ── BodyParser with a map (dynamic JSON) ───────────────────
	app.Post("/raw", func(c *fiber.Ctx) error {
		var body map[string]interface{}
		if err := c.BodyParser(&body); err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": err.Error(),
			})
		}
		return c.JSON(fiber.Map{"received": body})
	})

	// ── BodyParser reads body once — call c.Body() for re-reads
	// c.Body() returns the raw []byte body (safe to call multiple times)
	app.Post("/debug", func(c *fiber.Ctx) error {
		raw := c.Body() // read raw bytes for logging
		var input CreateUserInput
		if err := c.BodyParser(&input); err != nil {
			return fiber.NewError(fiber.StatusBadRequest, err.Error())
		}
		_ = raw // log raw for debugging
		return c.JSON(input)
	})

	// ── Configure body size limit (default: 4 MB) ─────────────
	// fiber.New(fiber.Config{BodyLimit: 10 * 1024 * 1024}) // 10 MB

	app.Listen(":3000")
}

Unknown JSON fields are silently ignored by encoding/json — if the client sends extra fields not present in the target struct, BodyParser succeeds without error. This is almost always the desired behavior for forward-compatible APIs. If you need to reject unknown fields (strict mode), implement a custom decoder using json.NewDecoder(bytes.NewReader(c.Body())).DisallowUnknownFields(). Required-field enforcement is not part of JSON decoding — a missing field leaves the struct field at its zero value. Use go-playground/validator after BodyParser to enforce required fields and value constraints.

Status Codes with JSON: c.Status().JSON()

c.JSON(data) always sends HTTP 200. To send a different status code with a JSON body, chain c.Status(code).JSON(data)Status() must be called before JSON(). Fiber provides named constants for all standard HTTP status codes in the fiber package: fiber.StatusCreated (201), fiber.StatusAccepted (202), fiber.StatusNoContent (204), fiber.StatusBadRequest (400), and so on.

package main

import "github.com/gofiber/fiber/v2"

type Item struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

func main() {
	app := fiber.New()

	// ── 201 Created — resource successfully created ─────────────
	app.Post("/items", func(c *fiber.Ctx) error {
		item := Item{ID: 42, Name: "Widget"}
		// Status() before JSON() — order matters
		return c.Status(fiber.StatusCreated).JSON(item)
	})

	// ── 202 Accepted — async job queued ────────────────────────
	app.Post("/jobs", func(c *fiber.Ctx) error {
		return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
			"jobId":  "job-abc-123",
			"status": "queued",
			"pollUrl": "/jobs/job-abc-123",
		})
	})

	// ── 204 No Content — DELETE with no body ───────────────────
	// Don't call c.JSON() for 204 — no body is expected
	app.Delete("/items/:id", func(c *fiber.Ctx) error {
		// delete logic here
		return c.SendStatus(fiber.StatusNoContent)
	})

	// ── 400 Bad Request ────────────────────────────────────────
	app.Get("/bad", func(c *fiber.Ctx) error {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "bad request",
			"code":  "INVALID_PARAM",
		})
	})

	// ── 404 Not Found ──────────────────────────────────────────
	app.Get("/items/:id", func(c *fiber.Ctx) error {
		// simulate not found
		found := false
		if !found {
			return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
				"error": "item not found",
				"id":    c.Params("id"),
			})
		}
		return c.JSON(Item{ID: 1, Name: "Widget"})
	})

	// ── Status constants reference ─────────────────────────────
	// fiber.StatusOK                  = 200
	// fiber.StatusCreated             = 201
	// fiber.StatusAccepted            = 202
	// fiber.StatusNoContent           = 204
	// fiber.StatusBadRequest          = 400
	// fiber.StatusUnauthorized        = 401
	// fiber.StatusForbidden           = 403
	// fiber.StatusNotFound            = 404
	// fiber.StatusUnprocessableEntity = 422
	// fiber.StatusInternalServerError = 500

	app.Listen(":3000")
}

Fiber's c.Status() returns the same *fiber.Ctx pointer, enabling method chaining. The pattern return c.Status(fiber.StatusBadRequest).JSON(data) sets the status code, encodes the JSON body, and returns the encoding error (or nil) in a single expression. For responses with no body, use c.SendStatus(code) which sends the HTTP status code with the status text as the body — appropriate for 204 No Content and 304 Not Modified.

Input Validation with go-playground/validator

c.BodyParser() decodes JSON structure but does not enforce business rules. go-playground/validator fills that gap with struct tags that specify constraints validated after decoding. Install with go get github.com/go-playground/validator/v10 and create a single package-level *validator.Validate instance — instantiating one per request is expensive and should be avoided.

package main

import (
	"github.com/go-playground/validator/v10"
	"github.com/gofiber/fiber/v2"
)

// ── Package-level validator — create once, reuse everywhere ───
var validate = validator.New()

// ── Input struct with validate tags ───────────────────────────
type RegisterInput struct {
	Name     string `json:"name"     validate:"required,min=2,max=100"`
	Email    string `json:"email"    validate:"required,email"`
	Password string `json:"password" validate:"required,min=8"`
	Age      int    `json:"age"      validate:"min=18,max=120"`
}

// ── Format ValidationErrors into a client-friendly map ────────
func formatValidationErrors(err error) map[string]string {
	errors := make(map[string]string)
	for _, e := range err.(validator.ValidationErrors) {
		// e.Field() = "Name", e.Tag() = "required", e.Param() = ""
		switch e.Tag() {
		case "required":
			errors[e.Field()] = e.Field() + " is required"
		case "email":
			errors[e.Field()] = "must be a valid email address"
		case "min":
			errors[e.Field()] = e.Field() + " must be at least " + e.Param()
		case "max":
			errors[e.Field()] = e.Field() + " must not exceed " + e.Param()
		default:
			errors[e.Field()] = "failed " + e.Tag() + " validation"
		}
	}
	return errors
}

func main() {
	app := fiber.New()

	app.Post("/register", func(c *fiber.Ctx) error {
		var input RegisterInput

		// Step 1: decode JSON body
		if err := c.BodyParser(&input); err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": "malformed JSON body",
			})
		}

		// Step 2: validate field constraints
		if err := validate.Struct(input); err != nil {
			return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
				"errors": formatValidationErrors(err),
			})
			// → {"errors":{"Email":"must be a valid email address","Password":"Password must be at least 8"}}
		}

		// input is valid — proceed with business logic
		return c.Status(fiber.StatusCreated).JSON(fiber.Map{
			"message": "user registered successfully",
			"email":   input.Email,
		})
	})

	// ── Custom validation rule ─────────────────────────────────
	// Register before handlers that use it
	validate.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
		s := fl.Field().String()
		for _, c := range s {
			if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
				return false
			}
		}
		return len(s) > 0
	})

	type PostInput struct {
		Title string `json:"title" validate:"required,max=200"`
		Slug  string `json:"slug"  validate:"required,slug"`
	}

	app.Post("/posts", func(c *fiber.Ctx) error {
		var input PostInput
		if err := c.BodyParser(&input); err != nil {
			return fiber.NewError(fiber.StatusBadRequest, err.Error())
		}
		if err := validate.Struct(input); err != nil {
			return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
				"errors": formatValidationErrors(err),
			})
		}
		return c.Status(fiber.StatusCreated).JSON(input)
	})

	app.Listen(":3000")
}

Common validate tags: required (non-zero value), email (RFC 5322 format), url (valid URL), uuid (UUID format), min=N / max=N (string length or numeric range), oneof=a b c (enum constraint), gt=0 (greater than zero), len=N (exact string length). Chain multiple tags with commas: validate:"required,min=3,max=50". Register the validator with validate.RegisterTagNameFunc to use json tag names in error messages instead of Go field names — this gives clients field names that match the JSON they sent.

JSON Error Responses and Custom Error Handler

Fiber provides two patterns for returning JSON errors. The inline pattern uses c.Status(code).JSON(payload) directly in the handler. The centralized pattern uses fiber.NewError(code, message) combined with a custom ErrorHandler in fiber.Config. The centralized approach keeps error formatting consistent across all handlers and avoids code duplication.

package main

import (
	"errors"
	"github.com/gofiber/fiber/v2"
)

// ── Custom JSON error handler ──────────────────────────────────
func jsonErrorHandler(c *fiber.Ctx, err error) error {
	// Default HTTP status for unknown errors
	code := fiber.StatusInternalServerError
	message := "internal server error"

	// Extract status code from fiber.Error
	var fiberErr *fiber.Error
	if errors.As(err, &fiberErr) {
		code = fiberErr.Code
		message = fiberErr.Message
	}

	// All errors returned as JSON — including 404, 405, panics
	return c.Status(code).JSON(fiber.Map{
		"error":  message,
		"status": code,
	})
}

func main() {
	// ── Register error handler globally ───────────────────────
	app := fiber.New(fiber.Config{
		ErrorHandler: jsonErrorHandler,
	})

	// ── Pattern 1: inline status + JSON ───────────────────────
	app.Get("/items/:id", func(c *fiber.Ctx) error {
		id := c.Params("id")
		if id == "0" {
			// Inline error — explicit status + JSON body
			return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
				"error": "item not found",
				"id":    id,
			})
		}
		return c.JSON(fiber.Map{"id": id, "name": "Widget"})
	})

	// ── Pattern 2: fiber.NewError → custom ErrorHandler ───────
	app.Post("/items", func(c *fiber.Ctx) error {
		var body struct {
			Name string `json:"name"`
		}
		if err := c.BodyParser(&body); err != nil {
			// Routes to jsonErrorHandler with 400
			return fiber.NewError(fiber.StatusBadRequest, "invalid JSON body")
		}
		if body.Name == "" {
			return fiber.NewError(fiber.StatusUnprocessableEntity, "name is required")
		}
		return c.Status(fiber.StatusCreated).JSON(body)
	})

	// ── Fiber's built-in errors ────────────────────────────────
	// fiber.ErrBadRequest          = &fiber.Error{Code: 400, Message: "Bad Request"}
	// fiber.ErrUnauthorized        = &fiber.Error{Code: 401, ...}
	// fiber.ErrForbidden           = &fiber.Error{Code: 403, ...}
	// fiber.ErrNotFound            = &fiber.Error{Code: 404, ...}
	// fiber.ErrMethodNotAllowed    = &fiber.Error{Code: 405, ...}
	// fiber.ErrUnprocessableEntity = &fiber.Error{Code: 422, ...}

	// ── Return built-in error directly ────────────────────────
	app.Get("/secure", func(c *fiber.Ctx) error {
		return fiber.ErrUnauthorized // ErrorHandler receives this
	})

	app.Listen(":3000")
}

The ErrorHandler also fires for Fiber's automatic 404 responses (no matching route) and 405 responses (route exists but wrong method). Without a custom error handler, these return plain text bodies — with a JSON error handler, every error response is consistently formatted as JSON. For production APIs, include a requestId in error responses for log correlation: read it from a middleware-set c.Locals("requestId") and include it in the error JSON object.

Configuring the JSON Encoder: sonic and go-json

Fiber's fiber.Config accepts JSONEncoder and JSONDecoder function fields that replace the default encoding/json implementation. Swapping to sonic (ByteDance) or go-json (Masaaki Goshima) reduces JSON serialization time by 2–3× on typical API payloads. Both libraries are API-compatible with encoding/json — the swap requires only the fiber.Config change.

package main

// ── Option A: sonic (ByteDance, SIMD, 2-3x faster) ────────────
// go get github.com/bytedance/sonic
import (
	"github.com/bytedance/sonic"
	"github.com/gofiber/fiber/v2"
)

func mainSonic() {
	app := fiber.New(fiber.Config{
		JSONEncoder: sonic.Marshal,
		JSONDecoder: sonic.Unmarshal,
		// sonic uses SIMD on amd64/arm64; falls back to encoding/json on other archs
	})
	app.Get("/", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{"encoder": "sonic"})
	})
	app.Listen(":3000")
}

// ── Option B: go-json (pure Go, 1.5-2x faster, portable) ──────
// go get github.com/goccy/go-json
import (
	gojson "github.com/goccy/go-json"
	"github.com/gofiber/fiber/v2"
)

func mainGoJSON() {
	app := fiber.New(fiber.Config{
		JSONEncoder: gojson.Marshal,
		JSONDecoder: gojson.Unmarshal,
		// Pure Go — works on all GOARCH values, no cgo dependency
	})
	app.Get("/", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{"encoder": "go-json"})
	})
	app.Listen(":3001")
}

// ── Option C: keep default encoding/json (no config needed) ───
func mainDefault() {
	app := fiber.New() // encoding/json is used automatically
	app.Get("/", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{"encoder": "encoding/json"})
	})
	app.Listen(":3002")
}

// ── Benchmark comparison (approximate, amd64, 1 KB payload) ───
// encoding/json: ~1.0× baseline (~500k marshal/sec)
// go-json:       ~1.5–2× faster  (~800k–1M marshal/sec)
// sonic:         ~2–3× faster    (~1M–1.5M marshal/sec)
//
// Run your own benchmark with: go test -bench=. -benchmem

Sonic uses AVX2/SSE4.2 SIMD instructions on amd64 and NEON on arm64. It detects architecture at runtime and falls back to encoding/json behavior on unsupported platforms — the swap is safe across architectures without build tags. The speedup is most pronounced for large payloads (1 KB+) with simple struct types. For small payloads or structs with complex custom MarshalJSON methods, the difference narrows. Run go test -bench=BenchmarkJSON -benchmem with your actual struct types before choosing an encoder.

JSON Middleware: Logging, CORS, and Rate Limiting

Fiber's middleware system wraps handlers with cross-cutting concerns. For JSON APIs, the three most important built-in middleware are logger (structured request logging), cors (Cross-Origin Resource Sharing headers), and limiter (rate limiting with JSON 429 responses). All are available in the github.com/gofiber/fiber/v2/middleware package hierarchy.

package main

import (
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/cors"
	"github.com/gofiber/fiber/v2/middleware/limiter"
	"github.com/gofiber/fiber/v2/middleware/logger"
	"github.com/gofiber/fiber/v2/middleware/recover"
	"github.com/gofiber/fiber/v2/middleware/requestid"
)

func main() {
	app := fiber.New(fiber.Config{
		ErrorHandler: func(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()})
		},
	})

	// ── Request ID — add to responses for log correlation ─────
	app.Use(requestid.New())

	// ── Recover from panics — returns 500 JSON via ErrorHandler
	app.Use(recover.New())

	// ── Structured request logger ──────────────────────────────
	app.Use(logger.New(logger.Config{
		Format: "${time} ${method} ${path} ${status} ${latency}
",
	}))

	// ── CORS — allow JSON API calls from browsers ──────────────
	app.Use(cors.New(cors.Config{
		AllowOrigins: "https://example.com, http://localhost:3000",
		AllowHeaders: "Origin, Content-Type, Accept, Authorization",
		AllowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
		MaxAge:       86400, // preflight cache: 24 hours
	}))

	// ── Rate limiter — return JSON 429 on breach ───────────────
	app.Use(limiter.New(limiter.Config{
		Max:        100,              // 100 requests
		Expiration: 1 * time.Minute, // per minute
		KeyGenerator: func(c *fiber.Ctx) string {
			return c.IP() // rate limit per client IP
		},
		LimitReached: func(c *fiber.Ctx) error {
			// Custom JSON 429 response
			return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
				"error":      "rate limit exceeded",
				"retryAfter": 60,
			})
		},
	}))

	// ── Routes ─────────────────────────────────────────────────
	api := app.Group("/api/v1")

	api.Get("/users", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{
			"users":     []string{"alice", "bob"},
			"requestId": c.Locals("requestid"), // from requestid middleware
		})
	})

	api.Post("/users", func(c *fiber.Ctx) error {
		var body struct {
			Name string `json:"name" validate:"required"`
		}
		if err := c.BodyParser(&body); err != nil {
			return fiber.NewError(fiber.StatusBadRequest, err.Error())
		}
		return c.Status(fiber.StatusCreated).JSON(body)
	})

	app.Listen(":3000")
}

Middleware registered with app.Use() applies to all subsequent routes. Register middleware in order of importance: requestid first (so all logs include the request ID), then recover (so panic recovery runs before other middleware can fail), then logger, then cors, and then limiter. Scope middleware to route groups with api.Use(limiter.New(...)) instead of app.Use() to limit only API routes while keeping health check endpoints unrestricted.

Key Terms

fasthttp
fasthttp is the high-performance HTTP library that Fiber is built on, as an alternative to Go's standard net/http package. It achieves higher throughput by reusing memory buffers aggressively — request and response objects are pooled and reused across requests rather than allocated fresh for each one. This reduces garbage collection pressure and enables Fiber to reach 1.6 million+ requests/second in benchmarks. The tradeoff is that Fiber's *fiber.Ctx is only valid within the handler's goroutine — any references held after the handler returns (goroutines, closures, channel sends) must copy the values they need using c.Locals(), string(c.Body()), or c.Request().CopyTo().
fiber.Map
fiber.Map is a type alias defined in the Fiber package as type Map = map[string]interface{"{}"}. It is semantically identical to a Go map with string keys and interface values — the alias exists purely for ergonomics, allowing you to write fiber.Map{"key": "value"} instead of map[string]interface{}{"key": "value"}. Because it is a type alias (not a named type), fiber.Map values are directly assignable to and from map[string]interface{"{}"} with no conversion. JSON key ordering is not guaranteed — Go maps are unordered, so the keys in the serialized JSON output may appear in any order. Use named structs with json tags when key ordering must be stable.
BodyParser
c.BodyParser(&target) is Fiber's request body decoder. It inspects the Content-Type header to select a decoder: application/json uses the configured JSONDecoder, application/xml uses encoding/xml, application/x-www-form-urlencoded uses URL-decoded key-value parsing, and multipart/form-data uses multipart parsing. The decoder populates the target struct with matching JSON fields; unrecognized fields are ignored. BodyParser does not enforce constraints — it only maps JSON structure to Go types. It returns a non-nil error for malformed JSON, wrong content type, or oversized bodies. The default body size limit is 4 MB, configurable via fiber.Config{"{"}BodyLimit{"}"}.
fiber.Error
*fiber.Error is Fiber's error type, implementing the Go error interface with an HTTP status Code field and a Message string. Create instances with fiber.NewError(code, message). When a handler returns a *fiber.Error, Fiber routes it to the app's ErrorHandler function. The ErrorHandler can inspect the error type and extract the status code with errors.As(err, &fiberErr). Fiber's built-in error constants (fiber.ErrNotFound, fiber.ErrBadRequest, etc.) are pre-instantiated *fiber.Error values. Returning a non-fiber error (e.g., a standard Go error) to the ErrorHandler results in HTTP 500.
sonic encoder
Sonic is a JSON library developed by ByteDance (TikTok's parent company) that provides 2–3× faster JSON serialization than encoding/json on x86_64 and ARM64 platforms. It achieves this via SIMD (Single Instruction, Multiple Data) CPU instructions — specifically AVX2 on x86_64 and NEON on ARM64 — which process multiple bytes in parallel. Sonic's API is a drop-in replacement for encoding/json: sonic.Marshal and sonic.Unmarshal have the same signatures. On architectures without SIMD support, Sonic automatically falls back to a pure Go implementation. Fiber integrates sonic by setting JSONEncoder: sonic.Marshal and JSONDecoder: sonic.Unmarshal in fiber.Config.
go-playground/validator
go-playground/validator (v10) is the standard Go struct validation library. It reads validate struct tags at runtime and enforces field-level constraints after JSON decoding. The validator.New() constructor creates a reusable *validator.Validate instance — create one at package level and reuse it across all handlers rather than instantiating per-request. validate.Struct(s) validates the struct and returns validator.ValidationErrors (which implements error) when any constraint fails. Each validator.FieldError in the slice provides Field() (struct field name), Tag() (the failing rule), and Param() (the rule's parameter, e.g., the minimum value for min=8). Custom validation rules are registered with validate.RegisterValidation("tagname", func).

FAQ

How do I return a JSON response in Fiber?

Call c.JSON(data) from any Fiber handler. The method accepts any Go value — a struct, a fiber.Map, a slice, or a primitive — serializes it with encoding/json (or your configured encoder), sets Content-Type: application/json automatically, and sends HTTP 200. You do not need to manually set the status code or Content-Type header for a standard 200 response. For non-200 responses, chain c.Status(fiber.StatusCreated).JSON(data)Status() must be called before JSON(). Fiber returns a Fiber error from c.JSON() only when the encoder fails; structs with non-serializable fields (channels, functions) will cause an error that you should handle in your custom error handler. For inline responses without defining a struct, fiber.Map{"key": "value"} is the idiomatic approach — it is a type alias for map[string]interface{} and works identically to any other map value passed to c.JSON().

How do I read a JSON request body in Fiber?

Use c.BodyParser(&target) where target is a pointer to the Go struct you want to populate. Fiber reads the raw request body, detects the Content-Type header, and routes to the appropriate decoder — application/json uses encoding/json, application/xml uses encoding/xml, and application/x-www-form-urlencoded uses URL-decoded form values. For JSON, the Content-Type header must be application/json; if it is missing or wrong, BodyParser returns fiber.ErrUnprocessableEntity (HTTP 422). Call BodyParser at the top of every POST and PUT handler before any business logic, check the returned error immediately, and return a JSON error response if it is non-nil. BodyParser does not validate field constraints — it only decodes the JSON structure into the struct. For validation, pass the populated struct to go-playground/validator after BodyParser succeeds. The default body size limit is 4 MB; configure fiber.Config{"{"}BodyLimit: 10 * 1024 * 1024{"}"} to change it.

What is fiber.Map and when should I use it?

fiber.Map is a type alias for map[string]interface{"{}"} defined in the Fiber package. It exists purely for convenience and readability — fiber.Map{"status": "ok", "count": 42} is identical at runtime to map[string]interface{}{"status": "ok", "count": 42}. Use fiber.Map for quick inline JSON responses where defining a dedicated struct would be over-engineering: health check endpoints, simple success acknowledgements, error detail objects, or one-off diagnostic responses. Avoid fiber.Map when the response shape is part of a public API contract or when you need TypeScript client code generation — in those cases, define a named Go struct with json struct tags so the shape is documented in code and tools can introspect it. fiber.Map values are serialized by encoding/json using its standard map rules: keys are always strings, values are encoded according to their underlying type, and the key order in the JSON output is not guaranteed to match insertion order (Go maps are unordered).

How do I validate JSON input in Fiber?

The standard pattern is two-step: c.BodyParser(&input) to decode the JSON, then validate.Struct(input) from go-playground/validator to enforce field constraints. First install the library: go get github.com/go-playground/validator/v10. Add validate struct tags to your input struct: validate:"required" makes a field mandatory, validate:"required,email" requires a valid email address, validate:"min=8,max=100" enforces string length or numeric range. Create a single package-level validator instance with validate := validator.New() — creating one per request is expensive. After BodyParser succeeds, call err := validate.Struct(input). If err is non-nil, cast it to validator.ValidationErrors to get a slice of field-level errors, each with Field(), Tag(), and Param() methods. Return these as a structured JSON 422 response.

How do I return a JSON error response in Fiber?

There are two patterns. The first is inline: return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "message", "code": "INVALID_INPUT"}). Call c.Status() before c.JSON() and return the result of c.JSON() to stop handler execution. The second is Fiber's centralized error handler: return fiber.NewError(fiber.StatusBadRequest, "invalid input") — Fiber routes this to the app's ErrorHandler function, keeping error formatting in one place. Configure a custom error handler with fiber.Config{"{"}ErrorHandler: func(c *fiber.Ctx, err error) error {"{"} ... {"}"} {"}"}. The custom error handler fires for both fiber.NewError() returns and for unhandled panics recovered by Fiber. For validation errors from go-playground/validator, convert ValidationErrors to a map of field names to error messages and return HTTP 422 — the 422 Unprocessable Entity status is more semantically correct than 400 Bad Request for schema-valid but semantically invalid JSON.

How does Fiber compare to Gin for JSON APIs?

Both Fiber and Gin are high-performance Go web frameworks with similar JSON APIs, but they differ in their underlying HTTP engines. Fiber is built on fasthttp, which bypasses Go's standard net/http package entirely, reaching 1.6 million requests per second in benchmarks — roughly 10 times Express.js throughput. Gin is built on net/http and httprouter, reaching around 100–200k req/s under similar conditions — still excellent, but meaningfully lower. The JSON APIs are similar: c.JSON(data) in Fiber versus c.JSON(200, data) in Gin (Gin requires the status code as the first argument). Gin uses c.ShouldBindJSON(&struct) for body parsing; Fiber uses c.BodyParser(&struct). Fiber's Context object is not goroutine-safe by design — fasthttp reuses context objects from a pool, requiring you to copy values needed after the handler returns. Choose Fiber when raw throughput and low memory allocation matter most; choose Gin when you prioritize net/http compatibility or plan to use the standard library's testing tools directly.

How do I configure Fiber to use a faster JSON encoder?

Fiber's default JSON encoder is encoding/json from the Go standard library. To swap it, pass a JSONEncoder and JSONDecoder to fiber.Config. The two most popular alternatives are sonic (ByteDance, 2–3× faster) and go-json (drop-in replacement, 1.5–2× faster). For sonic: go get github.com/bytedance/sonic, then fiber.New(fiber.Config{"{"} JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal {"}"}). For go-json: go get github.com/goccy/go-json, then fiber.New(fiber.Config{"{"} JSONEncoder: gojson.Marshal, JSONDecoder: gojson.Unmarshal {"}"}). Sonic uses SIMD instructions on amd64 and arm64 — on other architectures it falls back to encoding/json automatically. go-json is a pure Go implementation with no SIMD dependency, making it more portable. Both libraries are API-compatible with encoding/json for standard use cases. Benchmark with your actual payload shapes before committing to a library — the speedup varies by struct complexity and payload size.

How do I return a JSON list from a Fiber handler?

Pass a Go slice to c.JSON() and Fiber serializes it as a JSON array. For a slice of structs: items := []Item{"{"}{"{"}ID: 1, Name: "Alice"{"}"}, {"{"}ID: 2, Name: "Bob"{"}"}{"}"}; return c.JSON(items). This produces [{"{"}"id":1,"name":"Alice"{"}"},{"{""id":2,"name":"Bob"{"}"}] with Content-Type: application/json. For an empty list, always initialize the slice as items := make([]Item, 0) or items := []Item{"{}"} rather than var items []Item — an uninitialized nil slice serializes to JSON null, but an empty initialized slice serializes to [] which is almost always what API clients expect. For paginated lists, wrap the slice in a struct with Data, Total, Page, and PageSize fields, then pass that struct to c.JSON(). For lists retrieved from a database, pre-allocate with items := make([]Item, 0, expectedCount) to avoid repeated allocations during append operations.

Validate your Fiber JSON payloads instantly

Paste any JSON response from your Fiber handler into Jsonic's validator to check structure and catch errors before they reach clients.

Open JSON Validator

Further reading and primary sources