02/23/26

Chi vs Encore.go in 2026

Lightweight routing versus a full backend development platform

15 Min Read

Chi is a lightweight, composable router for Go that stays close to the standard library. It's fully compatible with net/http, which means every middleware written for Go's standard HTTP stack works with Chi out of the box. Rather than trying to be a framework, Chi focuses on doing routing well and getting out of the way for everything else. Encore.go takes a different approach by providing a complete backend development platform with built-in infrastructure primitives, type-safe service communication, and automatic observability.

The comparison here is really between two philosophies: assembling your own stack from small, focused tools (Chi + database/sql + OpenTelemetry + Docker + Terraform) versus using an integrated platform that handles all of those concerns together (Encore). Both are valid approaches, and the right choice depends on what you're building and how much control you want over the individual pieces.

Quick Comparison

AspectChiEncore.go
PhilosophyComposable router, close to stdlibInfrastructure-aware backend platform
net/http CompatibilityFull (uses standard http.Handler)Uses comment annotations, separate paradigm
Type SafetyStandard Go types, manual validationAutomatic request validation from struct types
Local InfrastructureBring your own (Docker, scripts)Automatic provisioning (databases, Pub/Sub)
Learning CurveVery low if you know net/httpLow, but new annotation patterns to learn
EcosystemAll net/http middleware worksDedicated infrastructure SDKs
ObservabilityManual setup (OpenTelemetry, etc.)Built-in distributed tracing
AI Agent CompatibilityManual configuration neededBuilt-in infrastructure awareness
Best ForTeams that want stdlib compatibility and composable routingDistributed systems with multiple services and infrastructure needs

The Basics: Defining an API

Let's start with a simple endpoint that greets a user by name.

Chi

package main

import (
    "encoding/json"
    "net/http"

    "github.com/go-chi/chi/v5"
)

type Response struct {
    Message string `json:"message"`
}

func main() {
    r := chi.NewRouter()

    r.Get("/hello/{name}", func(w http.ResponseWriter, r *http.Request) {
        name := chi.URLParam(r, "name")
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(Response{
            Message: "Hello, " + name + "!",
        })
    })

    http.ListenAndServe(":8080", r)
}

This is immediately familiar to anyone who has worked with net/http. The handler signature is the standard http.HandlerFunc, the response is written with json.NewEncoder, and path parameters are extracted with chi.URLParam. There's no magic here, just a thin routing layer on top of Go's standard library.

Encore.go

package hello

import "context"

type Response struct {
    Message string `json:"message"`
}

//encore:api public method=GET path=/hello/:name
func Hello(ctx context.Context, name string) (*Response, error) {
    return &Response{Message: "Hello, " + name + "!"}, nil
}

Encore uses a comment annotation to declare the API. The framework handles routing, server setup, and JSON serialization. Path parameters are passed directly as function arguments, and the return type defines the response shape. There's no manual encoding or header management.

Verdict: Chi keeps you in standard net/http territory, which means the patterns transfer directly to any Go HTTP code. Encore is more concise and removes boilerplate, but introduces its own annotation syntax. If staying close to the standard library matters to you, Chi wins on familiarity. If you want less boilerplate and automatic validation, Encore has the edge.

Routing and Composition

Chi supports composable routing. You can nest routers, group routes, and apply middleware at any level.

Chi

func main() {
    r := chi.NewRouter()

    // Global middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.RequestID)

    // Public routes
    r.Group(func(r chi.Router) {
        r.Get("/", homeHandler)
        r.Get("/health", healthHandler)
    })

    // API routes with authentication
    r.Route("/api", func(r chi.Router) {
        r.Use(authMiddleware)

        r.Route("/users", func(r chi.Router) {
            r.Get("/", listUsers)
            r.Post("/", createUser)
            r.Route("/{userID}", func(r chi.Router) {
                r.Get("/", getUser)
                r.Put("/", updateUser)
                r.Delete("/", deleteUser)
            })
        })

        r.Route("/posts", func(r chi.Router) {
            r.Get("/", listPosts)
            r.Post("/", createPost)
        })
    })

    http.ListenAndServe(":8080", r)
}

The Route and Group functions let you compose your application in a way that's easy to read and maintain. Middleware applied to a group only affects routes within that group, and subrouters keep related routes together. The tree-like structure mirrors how you think about your API.

Encore.go

// users/users.go
package users

import "context"

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

//encore:api public method=GET path=/api/users
func List(ctx context.Context) ([]*User, error) {
    // ...
}

//encore:api public method=POST path=/api/users
func Create(ctx context.Context, req *CreateUserRequest) (*User, error) {
    // ...
}

//encore:api public method=GET path=/api/users/:id
func Get(ctx context.Context, id int) (*User, error) {
    // ...
}
// posts/posts.go
package posts

import "context"

//encore:api public method=GET path=/api/posts
func List(ctx context.Context) ([]*Post, error) {
    // ...
}

//encore:api public method=POST path=/api/posts
func Create(ctx context.Context, req *CreatePostRequest) (*Post, error) {
    // ...
}

Encore organizes code by service rather than by route hierarchy. Each Go package represents a service, and API endpoints are individual functions annotated with their path and method. There's no explicit router configuration because Encore builds the routing table from your annotations at compile time.

Verdict: Chi's composable routing is clean and well-designed. If your application is a single service with complex route hierarchies and middleware layering, Chi gives you fine-grained control over the structure. Encore's service-based organization works well for distributed systems where each service has its own set of endpoints, but it doesn't offer the same level of route composition within a single service.

Database Integration

Chi is a router, not a framework, so database integration follows whatever patterns you prefer. Encore provides database primitives with automatic provisioning.

Chi

package main

import (
    "database/sql"
    "encoding/json"
    "net/http"

    "github.com/go-chi/chi/v5"
    _ "github.com/lib/pq"
)

var db *sql.DB

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

func main() {
    var err error
    db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    r := chi.NewRouter()
    r.Get("/users/{id}", getUser)
    http.ListenAndServe(":8080", r)
}

func getUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")

    var user User
    err := db.QueryRowContext(r.Context(),
        "SELECT id, name FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name)
    if err != nil {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

This is standard Go database code. You open a connection pool, pass the connection string from an environment variable, and use database/sql directly. You could swap in any database driver or ORM, use connection pooling libraries like pgxpool, or add migration tools like golang-migrate. The choice is entirely yours.

For local development, you typically run PostgreSQL in Docker:

docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres

export DATABASE_URL=postgres://postgres:secret@localhost:5432/myapp

# Run migrations with your preferred tool
migrate -path ./migrations -database $DATABASE_URL up

go run .

Encore.go

package users

import (
    "context"
    "encore.dev/storage/sqldb"
)

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

var db = sqldb.NewDatabase("users", sqldb.DatabaseConfig{
    Migrations: "./migrations",
})

//encore:api public method=GET path=/users/:id
func Get(ctx context.Context, id int) (*User, error) {
    var user User
    err := db.QueryRow(ctx,
        "SELECT id, name FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name)
    return &user, err
}

Running encore run provisions a local PostgreSQL database automatically, runs your migrations, and makes the database available to your code. No Docker setup, no environment variables, no connection string management. When you deploy, Encore provisions the appropriate managed database (RDS on AWS, Cloud SQL on GCP) in your cloud account.

Verdict: Chi lets you choose your own database tools, drivers, and patterns. That flexibility is useful when you have specific requirements or existing infrastructure. Encore eliminates the setup overhead entirely, which is a significant productivity gain for new projects, especially when you have multiple services each with their own database. The tradeoff is that Encore manages the database lifecycle for you, which means less control over connection pool settings and database configuration.

Middleware and Authentication

Chi

Chi's middleware system uses the standard net/http middleware signature, which means the entire Go ecosystem of HTTP middleware is available to you.

package main

import (
    "context"
    "encoding/json"
    "net/http"
    "strings"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/go-chi/cors"
)

type contextKey string
const userIDKey contextKey = "userID"

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
        if token == "" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        userID, err := validateToken(token)
        if err != nil {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), userIDKey, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func main() {
    r := chi.NewRouter()

    // Standard middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.RealIP)
    r.Use(cors.Handler(cors.Options{
        AllowedOrigins: []string{"https://example.com"},
        AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
    }))

    // Public routes
    r.Get("/health", healthHandler)

    // Protected routes
    r.Group(func(r chi.Router) {
        r.Use(authMiddleware)
        r.Get("/profile", func(w http.ResponseWriter, r *http.Request) {
            userID := r.Context().Value(userIDKey).(string)
            json.NewEncoder(w).Encode(map[string]string{"userId": userID})
        })
    })

    http.ListenAndServe(":8080", r)
}

Because Chi uses the standard http.Handler interface, you can plug in any middleware from the Go ecosystem. CORS, rate limiting, request logging, compression, JWT validation libraries, all of them work without adapters. Chi also ships with its own collection of middleware for common needs like logging, recovery, request IDs, and timeouts.

Encore.go

package auth

import (
    "context"
    "encore.dev/beta/auth"
)

type AuthParams struct {
    Authorization string `header:"Authorization"`
}

type AuthData struct {
    UserID string
}

//encore:authhandler
func AuthHandler(ctx context.Context, p *AuthParams) (auth.UID, *AuthData, error) {
    token := strings.TrimPrefix(p.Authorization, "Bearer ")
    userID, err := validateToken(token)
    if err != nil {
        return "", nil, err
    }
    return auth.UID(userID), &AuthData{UserID: userID}, nil
}
package profile

import (
    "context"
    "encore.dev/beta/auth"
)

//encore:api auth method=GET path=/profile
func GetProfile(ctx context.Context) (*ProfileResponse, error) {
    userData := auth.Data().(*auth.AuthData)
    return &ProfileResponse{UserID: userData.UserID}, nil
}

Encore provides a dedicated auth handler pattern. You define the handler once, and any endpoint annotated with auth instead of public requires authentication. The auth data is propagated automatically across service boundaries, which means downstream services can access the authenticated user without passing tokens around manually.

Verdict: Chi's middleware model is flexible. The compatibility with net/http middleware means existing Go middleware packages work without adapters. Encore's auth handler is more structured and handles cross-service auth propagation, but you give up the ability to drop in arbitrary net/http middleware. If you rely on specific middleware packages or need custom middleware chains, Chi is the more flexible choice. If you want structured authentication that propagates across services automatically, Encore handles that well.

Observability

Chi

Chi doesn't include observability features, but you can add them with standard Go libraries and the OpenTelemetry ecosystem.

package main

import (
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "go.opentelemetry.io/otel"
)

func main() {
    // Initialize OpenTelemetry (setup code omitted for brevity)
    initTracer()

    r := chi.NewRouter()
    r.Use(middleware.Logger)

    // Wrap routes with OpenTelemetry instrumentation
    r.Get("/users/{id}", getUser)

    handler := otelhttp.NewHandler(r, "chi-server")
    http.ListenAndServe(":8080", handler)
}

Since Chi uses net/http handlers, the standard OpenTelemetry HTTP middleware works directly. You'll also need to configure an exporter (Jaeger, Zipkin, OTLP), set up a collector, and instrument your database calls and outgoing HTTP requests separately. It's all possible, but it's a meaningful amount of setup and configuration, especially for distributed tracing across multiple services.

Encore.go

package users

import (
    "context"
    "encore.dev/rlog"
)

//encore:api public method=GET path=/users/:id
func Get(ctx context.Context, id int) (*User, error) {
    rlog.Info("fetching user", "id", id)
    // Database queries and service calls are traced automatically
    return fetchUser(ctx, id)
}

Every API call, database query, Pub/Sub message, and service-to-service call is traced automatically. The local development dashboard shows request traces in real time, and Encore Cloud provides production tracing without any additional setup.

Encore distributed tracing showing request flow across Go services

Verdict: Chi relies on external tooling, which gives you flexibility in choosing your observability stack but requires significant setup. Encore provides comprehensive observability out of the box, including distributed tracing across services. For teams that need distributed tracing and don't want to spend time configuring OpenTelemetry pipelines, Encore saves a lot of effort.

Microservices and Service Communication

This is where the two approaches diverge most significantly.

Chi

With Chi, each microservice is a separate application with its own router, deployed and managed independently.

// users-service/main.go
package main

import (
    "encoding/json"
    "net/http"

    "github.com/go-chi/chi/v5"
)

func main() {
    r := chi.NewRouter()

    r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := chi.URLParam(r, "id")
        user := User{ID: id, Name: "John"}
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(user)
    })

    http.ListenAndServe(":8081", r)
}
// orders-service/main.go
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"

    "github.com/go-chi/chi/v5"
)

func main() {
    r := chi.NewRouter()

    r.Post("/orders", func(w http.ResponseWriter, r *http.Request) {
        var req CreateOrderRequest
        json.NewDecoder(r.Body).Decode(&req)

        // Manual HTTP call to users service
        usersURL := os.Getenv("USERS_SERVICE_URL")
        resp, err := http.Get(fmt.Sprintf("%s/users/%s", usersURL, req.UserID))
        if err != nil {
            http.Error(w, "Failed to fetch user", http.StatusInternalServerError)
            return
        }
        defer resp.Body.Close()

        var user User
        json.NewDecoder(resp.Body).Decode(&user)

        order := Order{ID: 1, User: user}
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(order)
    })

    http.ListenAndServe(":8082", r)
}

Each service needs its own main.go, its own Dockerfile, its own deployment configuration, and its own service discovery setup. Cross-service calls are manual HTTP requests with no compile-time guarantees that the request or response types match. You also need to handle retries, timeouts, circuit breaking, and trace context propagation yourself.

Encore.go

// users/users.go
package users

import "context"

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

//encore:api public method=GET path=/users/:id
func Get(ctx context.Context, id string) (*User, error) {
    return &User{ID: id, Name: "John"}, nil
}
// orders/orders.go
package orders

import (
    "context"
    "encore.app/users"
)

type Order struct {
    ID   int         `json:"id"`
    User *users.User `json:"user"`
}

type CreateOrderRequest struct {
    UserID string `json:"userId"`
}

//encore:api public method=POST path=/orders
func Create(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    // Type-safe call to users service
    user, err := users.Get(ctx, req.UserID)
    if err != nil {
        return nil, err
    }
    return &Order{ID: 1, User: user}, nil
}

Service-to-service calls look like regular Go function calls. The compiler checks that your types match, and Encore handles service discovery, serialization, and trace propagation. Creating a new service is as simple as creating a new package with an API endpoint.

Encore service catalog showing service dependencies

Verdict: For microservices, Encore provides a dramatically better developer experience. Type-safe service calls, automatic service discovery, and no boilerplate HTTP client code. With Chi, you're responsible for all the cross-service plumbing, which adds up quickly as the number of services grows. If you're building a single service, this difference doesn't matter much. If you're building a distributed system with multiple services that communicate with each other, Encore removes a significant amount of operational complexity.

AI Code Generation

Go has strong opinions about many things, but how you structure a backend isn't one of them. That's usually fine for developers, but AI agents are the inverse: good at filling in details once a structure exists, bad at deciding what that structure should be.

Chi

When an AI agent writes code for a Chi project, it has to make architectural decisions on every prompt: how to structure subrouters and route groups, which middleware to compose and in what order, how to pass data through context.WithValue with custom key types, how to handle JSON encoding and error responses manually. The output works, but it varies between prompts, and the agent spends most of its effort on plumbing rather than your actual business logic.

// An agent asked to "add an order creation endpoint" might generate:
// - A new subrouter or modify an existing route group
// - A context.WithValue pattern with a different key type each time
// - Manual JSON decoding, encoding, and Content-Type header setup
// - Middleware composition that doesn't match the existing chain
// - A database connection pulled from a different package-level variable

Encore.go

With Encore, the project's API patterns, infrastructure declarations, and service structure are already defined. The agent reads the existing conventions and writes code that follows them. A prompt to "add an order creation endpoint" produces a function with an //encore:api annotation and a database call using the existing sqldb declaration, not a new architecture.

// The agent sees existing patterns and follows them:
//encore:api auth method=POST path=/orders
func Create(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    // Business logic only — the agent doesn't reinvent the structure
    var order Order
    err := db.QueryRow(ctx,
        "INSERT INTO orders (customer_id, total) VALUES ($1, $2) RETURNING id, customer_id, total",
        req.CustomerID, req.Total,
    ).Scan(&order.ID, &order.CustomerID, &order.Total)
    return &order, err
}

Encore also provides an MCP server and editor rules (encore llm-rules init) that give agents access to database schemas, distributed traces, and service architecture. This means agents can generate queries that match your actual tables, debug with real request data, and verify their own work. Read more in How AI Agents Want to Write Go.

Verdict: Chi requires the agent to make architectural decisions on every prompt, which produces working but inconsistent output. Encore gives agents conventions to follow and live system context through MCP, which leads to more consistent, deployable code.

When to Choose Chi

Chi is the right choice when:

  • You want to stay close to the standard library. Chi's handlers are standard http.HandlerFunc, and the patterns you learn transfer to any Go HTTP code. If you value this portability, Chi is a solid choice.
  • You need full net/http middleware compatibility. If your project depends on specific middleware packages, or your team has built middleware that uses the http.Handler interface, Chi works with all of it without adapters.
  • You prefer composable, small tools over integrated platforms. Chi does routing well and doesn't try to make decisions about your database, deployment, or observability. You pick each piece of the stack yourself.
  • You're building a single service with complex routing needs. Chi's subrouter and group composition model works well for organizing large route trees with different middleware at each level.
  • Your team already has infrastructure in place. If you have existing Kubernetes clusters, Terraform configurations, CI/CD pipelines, and monitoring, Chi fits neatly into that stack without asking you to change anything.

When to Choose Encore.go

Encore.go is the right choice when:

  • You're building a distributed system with multiple services. Type-safe service calls, automatic service discovery, and built-in distributed tracing make multi-service development significantly easier.
  • You want local infrastructure without Docker compose files. Encore provisions databases, Pub/Sub topics, and other resources automatically during local development.
  • You need built-in observability. Distributed tracing across services, database queries, and Pub/Sub messages without configuring OpenTelemetry pipelines or external collectors.
  • You want to move fast on a new project. Encore eliminates the setup time for databases, deployment pipelines, and monitoring, so you can focus on building your application logic.
  • You want deployment automation. Encore Cloud provisions infrastructure in your own AWS or GCP account, handling CI/CD, preview environments, and infrastructure scaling.
  • You're working with AI coding agents. Encore's declarative infrastructure makes it easier for AI agents to generate backend code that includes databases, Pub/Sub, and other resources without manual configuration.

Getting Started

Try both with a small project to see which tradeoffs matter most to your team:


Have questions about choosing a framework? Join our Discord community where developers discuss architecture decisions daily.

Ready to build your next backend?

Encore is the Open Source framework for building robust type-safe distributed systems with declarative infrastructure.