Updated Jun 23, 2026

What Echo Is & Your First Server

You already know Go, and you've maybe met Gin — the most popular Go web framework. Echo is its closest peer: another fast, focused layer over the standard library's net/http. If you've read the net/http roots guide, you know Go can serve HTTP with nothing but the stdlib. Echo, like Gin, does the repetitive parts — routing, JSON, middleware — without hiding what's underneath.

So why pick Echo over Gin, when they overlap so much? One design choice, mostly: in Echo, a handler returns an error. Not func(c *gin.Context) with no return value, where you write the error response by hand — but func(c echo.Context) error, where you hand the error back and a central handler turns it into an HTTP response. That sounds small. In a real codebase with dozens of endpoints, it's the difference between every handler re-implementing "how do I report a failure" and every handler saying return err. Less boilerplate, and errors that come out consistent because one place renders them all.

💡 Echo is net/http with helpers and opinions about errors. The helpers make it fast to write; the error-return style makes it stay clean as it grows. That's the whole pitch.

The mental model: instance holds routes, context handles the request, handlers return errors

Before any code, hold three things in your head. They are the entire framework.

📝 The instance (*echo.Echo, made with echo.New()) is your application. You create it once, register all your routes and middleware on it, and start it. When someone says "the Echo app," they mean this.

📝 The context (echo.Context) is one value handed to you for each incoming request. It carries the request, the response writer, the path and query params, and every helper you use to read input and write output. Note that echo.Context is an interface, not a struct — a detail that won't matter today but is good to know.

📝 The handler returns an error. Every handler you write has the shape func(c echo.Context) error. You do your work, then return c.JSON(...) on success or return someError on failure. Echo's central error handler decides what the client actually sees.

Say it once: the instance holds the routes, the context handles the request, and the handler returns an error. Everything else in Echo is detail.

flowchart LR
  E[echo.Echo<br/>holds the routes] --> R[route<br/>GET /ping]
  R --> H["handler<br/>func(c echo.Context) error"]
  H --> C[echo.Context<br/>reads input, writes output]
  H -.returns error.-> X[central error handler<br/>renders the response]

One idea: the instance matches an incoming request to a route, calls that route's handler, and the handler uses the context to send a response — or returns an error for the central handler to render. Every Echo endpoint flows along those arrows.

Your first server

First, install Echo into your module. From inside your Go project:

go get github.com/labstack/echo/v4

What just happened: go get downloaded Echo (the /v4 is the current major version) and added it to your go.mod/go.sum. The import path is github.com/labstack/echo/v4, and you refer to it in code as the echo package. That one command is the whole install.

Now the smallest server that does something real. Create a file called main.go:

package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()
    e.GET("/ping", func(c echo.Context) error {
        return c.JSON(http.StatusOK, map[string]string{"message": "pong"})
    })
    e.Logger.Fatal(e.Start(":1323"))
}

What just happened: line by line —

  • echo.New() creates the instance and returns a *echo.Echo. We name it e.
  • e.GET("/ping", ...) registers a route: when a GET request arrives for /ping, run the function we pass. That function is the handler, and its signature — func(c echo.Context) error — is the shape every Echo handler has.
  • Inside the handler, c.JSON(http.StatusOK, ...) uses the context to write the response: it sets the status to 200, sets Content-Type to application/json, serializes the value, and sends it. Crucially, c.JSON returns an error, and we return it — so if writing the response fails, Echo knows. On the happy path it returns nil, which Echo reads as "all good."
  • http.StatusOK is just the standard library's name for 200. Echo leans on net/http's constants rather than inventing its own — a reminder of what's underneath.
  • e.Start(":1323") starts the server listening on port 1323 (Echo's docs use that port; nothing magic about it). It blocks, handling requests until you stop the program. We wrap it in e.Logger.Fatal(...) so that if Start returns an error — say the port is already taken — it's logged and the program exits instead of failing silently.

Run it like any Go program:

go run main.go
$ go run main.go

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/  v4
⇨ http server started on [::]:1323

What just happened: go run compiled and started your program, and e.Start brought up the server. Echo printed its banner and is now waiting for requests. Leave it running and, in another terminal, hit the route:

$ curl localhost:1323/ping
{"message":"pong"}

What just happened: curl sent a GET /ping. The instance matched it to your route, called your handler, and the handler used the context to write back JSON — returning nil to signal success. You have a working JSON API in a dozen lines of real code.

The error-returning handler, and why it's different

This is the one place Echo and Gin diverge in a way worth pausing on. Put the two handler shapes side by side:

// Gin: no return value — you write the response (and any error) yourself.
func(c *gin.Context) {
    c.JSON(200, gin.H{"message": "pong"})
}

// Echo: return an error — success or failure flows back to a central handler.
func(c echo.Context) error {
    return c.JSON(http.StatusOK, map[string]string{"message": "pong"})
}

What just happened: both write the same JSON. But the Echo version returns — and that return value is the hook. When something goes wrong, you don't have to write a status code and an error body by hand every time. You return an error, and Echo's central error handler renders it. Echo even gives you a purpose-built error type for this:

e.GET("/secret", func(c echo.Context) error {
    return echo.NewHTTPError(http.StatusUnauthorized, "you shall not pass")
})

What just happened: instead of manually setting a 401 status and writing a JSON body, you returned an *echo.HTTPError describing the failure. Echo's default error handler turns it into a clean 401 response with a JSON message. Every endpoint that returns this kind of error gets the same consistent shape — no copy-pasted error-writing code.

⚠️ Don't worry about configuring that central handler yet — that's Phase 6's job, where we wire up a custom HTTPErrorHandler for the books API. For now, just internalize the habit: in Echo, you return your result, success or failure. Forgetting the return is the rookie Echo bug — the handler compiles, but nothing gets sent and you stare at a hung request wondering why.

Adding Logger and Recover middleware

Here's a sharp difference from Gin worth knowing on day one. Gin's gin.Default() hands you a Logger and a Recovery handler already wired up. Echo's echo.New() does not — it gives you a bare instance. Logging and panic-recovery are opt-in. Most apps want both, so you add them yourself:

package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

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

    e.Use(middleware.Logger())  // a tidy log line per request
    e.Use(middleware.Recover()) // catch panics, return 500, stay alive

    e.GET("/ping", func(c echo.Context) error {
        return c.JSON(http.StatusOK, map[string]string{"message": "pong"})
    })

    e.Logger.Fatal(e.Start(":1323"))
}

What just happened: e.Use(...) registers middleware — code that runs around every request. We imported github.com/labstack/echo/v4/middleware (a separate package from echo) and added two pieces:

  • middleware.Logger() prints a line for every request — method, path, status, how long it took. It's how you see your server working.
  • middleware.Recover() catches a panic inside any handler, turns it into a clean 500 response, and keeps the server running. Without it, one panicking handler takes down the whole process.

We'll cover the middleware signature and write our own in Phase 5. ⚠️ The takeaway for now: unlike Gin, Echo doesn't include these by default — if your server runs silent or dies on a panic, it's because you haven't added them yet.

The running example: a books API

We won't keep writing throwaway /ping routes. Across this guide we'll grow one real service: a small books API. The core of it is a single type — a book with an id, a title, and an author:

type Book struct {
    ID     int    `json:"id"`
    Title  string `json:"title"`
    Author string `json:"author"`
}

What just happened: we declared the Book struct the whole guide builds on. Those json:"..." struct tags tell Echo what to call each field in JSON — so Title becomes "title", not "Title". Tags work in both directions; binding incoming JSON in Phase 3 leans on them too. Here's the type returning itself through the now-familiar flow:

e.GET("/books/sample", func(c echo.Context) error {
    b := Book{ID: 1, Title: "The Go Programming Language", Author: "Donovan & Kernighan"}
    return c.JSON(http.StatusOK, b)
})

What just happened: the handler built a Book, and return c.JSON(...) serialized it using those tags — no map needed when you already have a struct. Hit it and you get clean JSON back:

$ curl localhost:1323/books/sample
{"id":1,"title":"The Go Programming Language","author":"Donovan & Kernighan"}

By the end of the guide this grows into full create/read/update/delete over a real collection of books, with centralized error handling and tests. For now you've met the cast: an instance, a route, a handler that returns an error, a context, and the Book we'll spend the next phases turning into a proper REST API. Next up: routing — path params, query params, and grouping routes so they don't sprawl.

Recap

  • Echo is a fast Go web framework over net/http — a close peer of Gin. It does the repetitive parts (routing, JSON, middleware) without hiding the standard library underneath.
  • The mental model is three things: the instance (*echo.Echo, from echo.New()) holds your routes; the context (echo.Context, an interface) handles each request; the handler returns an errorfunc(c echo.Context) error.
  • Echo's signature trait is the error-returning handler. You return c.JSON(...) on success or return echo.NewHTTPError(...) on failure, and a central handler renders it. ⚠️ Forgetting the return is the classic Echo bug.
  • A first server is tiny: echo.New() makes the instance, e.GET(path, handler) registers a route, c.JSON(http.StatusOK, ...) writes the response, and e.Start(":1323") listens. Run with go run main.go, test with curl.
  • Unlike Gin, Echo includes no middleware by default. Add middleware.Logger() and middleware.Recover() yourself (from github.com/labstack/echo/v4/middleware) for request logging and crash protection.
  • The throughline: instance → route → handler → context → response, with errors flowing to a central handler. We'll grow one books API along that path for the rest of the guide.

Quick check

Three questions on the ideas that have to stick — what Echo is, the instance/context split, and the error-returning handler:

[
  {
    "q": "What is Echo's signature difference from Gin in how handlers work?",
    "choices": [
      "An Echo handler returns an error (func(c echo.Context) error), and a central handler renders it; a Gin handler returns nothing and writes errors by hand",
      "Echo handlers take no arguments at all",
      "Echo handlers must return a string that becomes the response body",
      "Echo handlers run in a separate goroutine automatically"
    ],
    "answer": 0,
    "explain": "Echo handlers are func(c echo.Context) error. You return c.JSON(...) on success or return an error on failure, and Echo's central error handler turns errors into responses. Gin's func(c *gin.Context) has no return value, so you write error responses yourself."
  },
  {
    "q": "What does echo.New() give you that gin.Default() includes but echo.New() does not?",
    "choices": [
      "Nothing extra — echo.New() returns a bare instance, so you add Logger and Recover middleware yourself with e.Use(...)",
      "A built-in database connection",
      "Automatic HTTPS certificates",
      "Logger and Recover middleware, already wired up like gin.Default()"
    ],
    "answer": 0,
    "explain": "echo.New() returns a bare *echo.Echo with no middleware. Unlike gin.Default() (which ships Logger + Recovery), in Echo you opt in: e.Use(middleware.Logger()) and e.Use(middleware.Recover()) from github.com/labstack/echo/v4/middleware."
  },
  {
    "q": "In the mental model, what are the roles of the instance and the context?",
    "choices": [
      "The instance (*echo.Echo) holds your routes and is started once; the context (echo.Context) is handed to a handler for each request to read input and write output",
      "They are the same object with two names",
      "The context holds the routes and the instance handles each request",
      "The instance is the JSON serializer and the context is the router"
    ],
    "answer": 0,
    "explain": "Instance holds the routes, context handles the request. You create one *echo.Echo, register routes on it, and start it; each incoming request gets an echo.Context carrying the request, response writer, params, and helpers. Every handler is func(c echo.Context) error."
  }
]

Guide overview · Phase 2: Routing & Groups →

Check your understanding

1. What is Echo's signature difference from Gin in how handlers work?

2. What does echo.New() give you that gin.Default() includes but echo.New() does not?

3. In the mental model, what are the roles of the instance and the context?

Was this page helpful?