REST API with Echo

You build a REST API with Echo by initializing an `echo.Echo` instance, defining routes with HTTP methods, and attaching handler functions that read request data and write JSON responses.

REST API with Echo

You're building a service that needs to handle JSON requests, parse path parameters, and return consistent error responses. The standard library net/http works, but you find yourself writing the same boilerplate for binding JSON, checking content types, and formatting errors across every handler. You want a framework that gives you structure without hiding the mechanics of HTTP. That's where Echo comes in. It's a lightweight web framework for Go that adds routing, middleware, and request binding on top of the standard library.

How Echo fits in

Echo sits between your code and the raw HTTP connection. Think of it like a receptionist at a busy office. The receptionist checks the ID badge (middleware), directs the visitor to the right room (routing), hands them the form they need (binding), and makes sure they leave with the right paperwork (response formatting). You still write the logic inside the rooms, but the receptionist handles the flow. Echo keeps the standard http.Handler interface under the hood, so nothing is magic. You can still swap pieces out or drop back to the standard library if needed.

Minimal example

Here's the simplest Echo server: define routes, bind data, return JSON.

package main

import (
	"net/http"
	"github.com/labstack/echo/v4"
)

// User represents a simple user entity.
type User struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

func main() {
	// New creates the router and middleware stack.
	e := echo.New()

	// GET /users/:id extracts the ID from the URL path.
	e.GET("/users/:id", func(c echo.Context) error {
		// Param retrieves the value bound to the named path segment.
		id := c.Param("id")
		// In a real app, you'd query a database using id.
		user := User{ID: 1, Name: "Alice"}
		// JSON serializes the struct and sets the Content-Type header.
		return c.JSON(http.StatusOK, user)
	})
}

Echo handles the plumbing. You handle the logic.

The POST endpoint reads the request body. Add this to the same file to complete the example.

// POST /users reads the request body into a struct.
e.POST("/users", func(c echo.Context) error {
	var user User
	// Bind decodes the JSON body into the struct.
	if err := c.Bind(&user); err != nil {
		return err
	}
	// StatusCreated signals a successful resource creation.
	return c.JSON(http.StatusCreated, user)
})

// Start listens on port 8080 and blocks until the process exits.
e.Start(":8080")

What happens at runtime

When you run this, Echo initializes the router and starts a listener on port 8080. The e.GET and e.POST calls register handler functions in a tree structure. When a request arrives, Echo matches the path and method to the registered handler.

For the GET request, c.Param("id") pulls the value from the URL. If the URL is /users/42, id becomes "42". The handler returns a User struct wrapped in c.JSON. Echo marshals the struct to JSON, sets the Content-Type to application/json, writes the status code, and sends the response.

For the POST request, c.Bind reads the request body. It checks the Content-Type header. If it's JSON, Echo uses encoding/json to unmarshal the body into the User struct. If the JSON is malformed or missing required fields, Bind returns an error. Returning that error from the handler triggers Echo's error handler, which sends a 400 response with details about the binding failure.

Middleware runs in a chain. Echo adds middleware globally with e.Use. The order matters. The first middleware added wraps the handler first. When a request arrives, it passes through the middleware stack, then the handler, then back through the middleware stack. If you add Logger before Recover, the log runs even if a panic occurs. Recover catches the panic and stops the chain. Logger still runs because it's outside Recover. This means you get logs even when panics happen.

Middleware runs in order. The first middleware added is the first to see the request.

Realistic setup

Production APIs need middleware for logging, recovery, and cross-origin requests. They also group routes for versioning and handle errors consistently.

Here's a robust server configuration with middleware and route grouping.

package main

import (
	"net/http"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()

	// Logger writes request details to stdout for observability.
	e.Use(middleware.Logger())
	// Recover catches panics and returns a 500 error instead of crashing.
	e.Use(middleware.Recover())
	// CORS allows browsers to make cross-origin requests.
	e.Use(middleware.CORS())

	// Group prefixes all routes with /v1 for API versioning.
	v1 := e.Group("/v1")

	// Health check endpoint for load balancers and monitoring.
	v1.GET("/health", func(c echo.Context) error {
		return c.String(http.StatusOK, "OK")
	})
}

Groups can have their own middleware. Call v1.Use(middleware.JWT()) to protect all routes in the group. This keeps authentication logic out of individual handlers. The middleware runs before the handler. If the token is invalid, the middleware returns an error. The handler never runs. This pattern scales well for large APIs.

Handlers receive echo.Context. This interface wraps http.Request and http.ResponseWriter. It adds helpers like Param, QueryParam, Bind, and JSON. Always extract the standard context for cancellation. Call ctx := c.Request().Context(). Pass ctx to database queries. If the client closes the connection, the context cancels. Your database call stops. This prevents goroutine leaks and wasted resources.

Custom error handlers keep your API consistent. Don't leak stack traces to clients.

Override the error handler to return structured JSON for all errors.

// Custom error handler ensures all errors return consistent JSON.
e.HTTPErrorHandler = func(err error, c echo.Context) error {
	code := http.StatusInternalServerError
	// HTTPError wraps errors with a specific status code.
	if he, ok := err.(*echo.HTTPError); ok {
		code = he.Code
	}
	// Return a structured error object instead of a raw message.
	return c.JSON(code, map[string]string{"error": err.Error()})
}

e.Start(":8080")

Pitfalls and conventions

Echo makes mistakes easy to spot, but a few traps exist. If you return an error from a handler without wrapping it, Echo treats it as an internal server error. Use echo.NewHTTPError to set the correct status code. If you forget to import a package, the compiler rejects the program with undefined: pkg. If you define a route but never call e.Start, the server never listens. The compiler won't catch that; you'll just get a connection refused error at runtime.

Binding pitfalls are common. c.Bind returns an error if the JSON is invalid. If you ignore the error, the struct contains zero values. The compiler complains with err declared and not used if you assign the error to a variable and don't check it. Go forces you to handle errors explicitly. This verbosity prevents silent failures. The community accepts the boilerplate because it makes the unhappy path visible.

Path parameters are strings. c.Param returns a string. If you need an integer, you must parse it. strconv.Atoi returns an error. If you skip the parse, you might panic later with invalid syntax or get wrong data. Always parse and check.

Struct fields must be exported for JSON marshaling. If Name is lowercase, the JSON output omits the field. The json tag controls the key name in the output. json:"name" makes the key lowercase even if the field is Name. Public names start with a capital letter. Private start lowercase. No keywords like public or private.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. If you spawn a goroutine in a handler to do background work, and that goroutine waits on a channel that never closes, it leaks. The handler returns, the request finishes, but the goroutine stays alive. Always pass a context with a timeout or cancellation to background goroutines. Check the context in the loop. Stop when the context is done. The worst goroutine bug is the one that never logs.

If you forget to capture a loop variable in a closure, the compiler rejects the program with loop variable i captured by func literal. This became a hard error in Go 1.22. Echo handlers are often closures. If you iterate over routes and define handlers in a loop, capture the variable. Assign it to a new variable inside the loop.

Go code runs through gofmt. Echo follows standard Go formatting. Run gofmt on your files. Most editors run it on save. Don't argue about indentation. The tool decides. This keeps the codebase consistent across teams. Trust gofmt. Argue logic, not formatting.

The compiler catches unused variables. Runtime catches logic errors. Test both.

When to use Echo

Use Echo when you need a lightweight framework with routing, middleware, and binding for a REST API. Use the standard library net/http when you want zero dependencies and full control over the HTTP stack. Use a heavier framework like Gin or Fiber when you need advanced features like template rendering or WebSocket support out of the box. Use gRPC when performance and type safety across services matter more than browser compatibility.

Pick the tool that matches the complexity. Simple APIs don't need heavy frameworks.

Where to go next