Exposing your logic to the world
You have a Go program that calculates something, stores data, or orchestrates a workflow. Now you need to expose it to other services, a frontend, or a CLI tool. Writing raw net/http handlers works for a quick script, but once you need routing, JSON binding, middleware, and error handling, the boilerplate piles up. You end up writing the same pattern matching and header setting code in every handler.
Echo is a web framework that wraps the standard library to give you a router, a context object, and a middleware chain. It feels like Go: explicit, fast, and opinionated about structure without hiding the underlying mechanics. You write handlers that take a context and return an error. Echo handles the rest.
Echo as a router and middleware chain
Echo revolves around the Context. Every request gets a new Context that carries the http.Request, the http.ResponseWriter, and helper methods for binding, validation, and response writing. You define routes by mapping an HTTP method and path to a handler function. Echo matches incoming requests to these routes and invokes the handler with the context.
Middleware sits between the router and your handler. It can inspect the request, modify the context, or short-circuit the response. You apply middleware globally to all routes or selectively to specific groups. Echo provides built-in middleware for logging, recovery, CORS, and rate limiting. You can also write custom middleware for authentication or tracing.
Think of Echo as a receptionist. You define the rules for how to handle visitors. Echo greets them, checks their credentials, hands them to the right department, and if something goes wrong, Echo apologizes to the visitor in the right format. You just tell Echo what to do with the visitor.
Minimal server
Here's the absolute minimum: create an Echo instance, add recovery middleware, define a route, and start listening.
package main
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
// New initializes the router and middleware chain.
e := echo.New()
// Recover prevents panics from crashing the server.
e.Use(middleware.Recover())
// GET binds the path to an anonymous handler function.
e.GET("/hello", func(c echo.Context) error {
// JSON writes the response with the correct Content-Type header.
return c.JSON(http.StatusOK, map[string]string{"message": "world"})
})
// Start blocks the main goroutine while listening for requests.
e.Logger.Fatal(e.Start(":8080"))
}
Run this with go run main.go. The server listens on port 8080. Hit http://localhost:8080/hello and you get {"message":"world"}.
e.Logger.Fatal is a convention. Start returns an error only if the port is already in use or there's a setup failure. Fatal logs the error and calls os.Exit(1). This is standard for main functions in Go services. If the server can't start, the process should die.
Request lifecycle
When a request arrives, Echo matches the path and method. It creates a new Context for that request. The context holds the request data, response writer, and utilities. Echo runs the middleware chain. Each middleware can call next(c) to proceed to the next middleware or handler. If a middleware returns an error, the chain stops and Echo sends the error response.
If the chain reaches your handler, Echo calls it. Your handler does its work and returns an error. If the error is nil, Echo sends a 200 OK with the body you wrote. If the error is not nil, Echo converts it to an HTTP response. If you return an echo.HTTPError, Echo uses the status code and message you provided. If you return a raw error, Echo wraps it in a 500 Internal Server Error.
Middleware is the interceptor pattern. Use it for cross-cutting concerns, not business logic.
Middleware and cross-cutting concerns
Middleware wraps handlers. It can inspect the request, modify the context, or short-circuit the response. Here's how to write a simple auth middleware that checks for a token.
// AuthMiddleware checks for a valid token in the header.
func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Extract token from the Authorization header.
token := c.GetHeader("Authorization")
if token == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "missing token")
}
// Call next handler if token is present.
return next(c)
}
}
Apply this middleware globally with e.Use(AuthMiddleware). Or apply it to a route group: g := e.Group("/api/v1"); g.Use(AuthMiddleware). Groups let you prefix paths and share middleware. This is how you version APIs and protect endpoints.
Run gofmt on your code. Echo doesn't care about formatting, but the Go community expects standard formatting. Most editors run it on save.
Realistic handler structure
Real APIs need validation, error handling, and separation of concerns. Define request structs with JSON tags. Use a handler struct to hold dependencies. Validate input explicitly. Return safe errors.
Define the input shape explicitly. Echo uses struct tags to map JSON keys to fields.
// CreateUserRequest defines the JSON fields the client must send.
type CreateUserRequest struct {
Name string `json:"name"`
}
The handler binds the body, validates, calls the store, and returns a response.
// CreateUser validates input and adds a user to the store.
func (h *UserHandler) CreateUser(c echo.Context) error {
var input CreateUserRequest
// Bind decodes the request body; it returns an error on malformed JSON.
if err := c.Bind(&input); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid JSON")
}
// Explicit validation ensures business rules are enforced.
if input.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "name is required")
}
user, err := h.store.Create(input.Name)
if err != nil {
// Return a generic error to avoid leaking internal details.
return echo.NewHTTPError(http.StatusInternalServerError, "server error")
}
return c.JSON(http.StatusCreated, user)
}
Receiver naming follows Go convention. (h *UserHandler) uses a short name matching the type. This is standard style. Also, check every error. Echo's Bind can fail. If you ignore the error, you might process a zero-value struct and create invalid data.
Context, grouping, and validation
The context gives you access to everything. c.QueryParam("id") gets URL parameters. c.FormValue("name") gets form data. c.Bind works for JSON, form, and multipart requests. c.Param("id") gets path parameters defined in the route, like /users/:id.
Echo supports validation with go-playground/validator. Set e.Validator to a validator instance. Add validate tags to your structs. Echo runs validation automatically when you call c.Bind. This keeps validation logic declarative and close to the data definition.
Context carries the request lifecycle. Treat it as read-only in handlers.
Pitfalls and compiler errors
Common mistakes include mixing standard library handlers with Echo routes, forgetting to check errors, or returning raw errors that expose stack traces.
If you pass a standard library handler to an Echo route, the compiler rejects it. cannot use handler (variable of type func(http.ResponseWriter, *http.Request)) as echo.HandlerFunc value in argument. Echo handlers must return an error. Wrap standard handlers with echo.WrapHandler if you need to reuse them.
If you try to return a string instead of an error, the compiler rejects it with cannot use string literal (type string) as error value in return argument. Handlers must return an error type.
If you access c.Param without defining the parameter in the route, you get an empty string. This isn't a panic, but a logic bug. Define parameters in the route path: e.GET("/users/:id", getUser).
Handlers must return errors. Never swallow them.
When to use Echo
Use Echo when you need a fast, lightweight framework with a clean API and extensive middleware ecosystem. Use Gin when you prefer a different middleware style or need higher throughput benchmarks in specific micro-benchmarks. Use the standard library net/http when you want zero dependencies and your routing needs are simple. Use Chi when you want a router that feels like the standard library but adds context keys and group middleware. Use Fiber when you need async handlers and Express.js-like syntax, accepting the trade-off of goroutine safety concerns.
Pick the tool that matches your team's familiarity and the project's scale.