02/23/26

How to Build a REST API with Go in 2026

A practical guide to building type-safe APIs with modern Go tooling

12 Min Read

Building a REST API with Go is a good default choice for backend work in 2026. The language compiles fast, runs fast, and has a standard library that covers most of what you need. Modern tooling takes it further by handling infrastructure setup, type-safe routing, and deployment automatically. This guide walks you through building a complete API from scratch.

What You'll Build

A task management API with:

  • CRUD endpoints for tasks
  • PostgreSQL database with migrations
  • Input validation
  • Error handling
  • Authentication

We'll use Encore.go for this tutorial because it removes most infrastructure setup, but the patterns apply to any Go backend.

Prerequisites

  • Go 1.21+ installed
  • Basic Go knowledge
  • A terminal

Project Setup

First, install the Encore CLI. This gives you the encore command for creating projects, running your app locally, and deploying to the cloud. The CLI handles database provisioning, environment management, and more.

# macOS
brew install encoredev/tap/encore

# Linux
curl -L https://encore.dev/install.sh | bash

# Windows
iwr https://encore.dev/install.ps1 | iex

Create a new project. The --example flag starts you with a working hello world template that includes the basic project structure and a sample endpoint.

encore app create task-api --example=go/hello-world
cd task-api

Start the development server. Encore compiles your Go code, starts any required infrastructure (like databases), and serves your API. You'll also get a local development dashboard for testing and debugging.

encore run

You now have a running API at http://localhost:4000 with a local development dashboard at http://localhost:9400.

Encore local development dashboard

Your First Endpoint

Open hello/hello.go. You'll see a basic endpoint that demonstrates the core API pattern. Notice how the request and response types are defined as Go structs, and the endpoint is declared with the //encore:api annotation.

package hello

import "context"

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

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

This demonstrates the core pattern:

  1. Define request/response structs with JSON tags for serialization
  2. Annotate the function with //encore:api to declare it as an endpoint with an HTTP method and path
  3. Go's type system and Encore's code generation validate everything at compile time, catching errors before they reach production

Test it by making a request. The endpoint automatically parses the path parameter and returns JSON.

curl http://localhost:4000/hello/world
# {"message":"Hello, world!"}

Creating the Task Service

Now let's build something more substantial. We'll create a dedicated service for managing tasks. In Encore.go, a service is simply a Go package that contains //encore:api annotated functions. There's no special service declaration file needed.

Create a new directory for the service.

mkdir tasks

Create tasks/tasks.go with CRUD endpoints. We'll start with in-memory storage to keep things simple, then add a database in the next section.

package tasks

import (
    "context"
    "sync"

    "encore.dev/beta/errs"
    "github.com/google/uuid"
)

type Task struct {
    ID          string `json:"id"`
    Title       string `json:"title"`
    Description string `json:"description"`
    Completed   bool   `json:"completed"`
}

type CreateTaskRequest struct {
    Title       string `json:"title"`
    Description string `json:"description,omitempty"`
}

type UpdateTaskRequest struct {
    Title       *string `json:"title,omitempty"`
    Description *string `json:"description,omitempty"`
    Completed   *bool   `json:"completed,omitempty"`
}

type ListTasksResponse struct {
    Tasks []*Task `json:"tasks"`
}

// In-memory storage (we'll add a database next)
var (
    mu        sync.Mutex
    taskStore = map[string]*Task{}
)

//encore:api public method=POST path=/tasks
func Create(ctx context.Context, req *CreateTaskRequest) (*Task, error) {
    task := &Task{
        ID:          uuid.New().String(),
        Title:       req.Title,
        Description: req.Description,
        Completed:   false,
    }
    mu.Lock()
    taskStore[task.ID] = task
    mu.Unlock()
    return task, nil
}

//encore:api public method=GET path=/tasks/:id
func Get(ctx context.Context, id string) (*Task, error) {
    mu.Lock()
    task, ok := taskStore[id]
    mu.Unlock()
    if !ok {
        return nil, &errs.Error{Code: errs.NotFound, Message: "task not found"}
    }
    return task, nil
}

//encore:api public method=GET path=/tasks
func List(ctx context.Context) (*ListTasksResponse, error) {
    mu.Lock()
    tasks := make([]*Task, 0, len(taskStore))
    for _, t := range taskStore {
        tasks = append(tasks, t)
    }
    mu.Unlock()
    return &ListTasksResponse{Tasks: tasks}, nil
}

//encore:api public method=PATCH path=/tasks/:id
func Update(ctx context.Context, id string, req *UpdateTaskRequest) (*Task, error) {
    mu.Lock()
    task, ok := taskStore[id]
    if !ok {
        mu.Unlock()
        return nil, &errs.Error{Code: errs.NotFound, Message: "task not found"}
    }
    if req.Title != nil {
        task.Title = *req.Title
    }
    if req.Description != nil {
        task.Description = *req.Description
    }
    if req.Completed != nil {
        task.Completed = *req.Completed
    }
    mu.Unlock()
    return task, nil
}

//encore:api public method=DELETE path=/tasks/:id
func Delete(ctx context.Context, id string) error {
    mu.Lock()
    _, ok := taskStore[id]
    if ok {
        delete(taskStore, id)
    }
    mu.Unlock()
    if !ok {
        return &errs.Error{Code: errs.NotFound, Message: "task not found"}
    }
    return nil
}

A few things to notice about the Go patterns here. Path parameters like :id are passed as function arguments, and the PATCH endpoint takes both a path parameter (id) and a request body (*UpdateTaskRequest). Optional update fields use pointer types (*string, *bool) so you can distinguish between "not provided" and "set to zero value."

Test the endpoints to make sure everything works.

# Create a task
curl -X POST http://localhost:4000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Go", "description": "Build a REST API"}'

# List tasks
curl http://localhost:4000/tasks

# Get a specific task (use the ID from create response)
curl http://localhost:4000/tasks/<task-id>

# Update a task
curl -X PATCH http://localhost:4000/tasks/<task-id> \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

# Delete a task
curl -X DELETE http://localhost:4000/tasks/<task-id>

Adding a Database

In-memory storage won't survive restarts, and you can't scale to multiple instances. Let's add PostgreSQL for persistent storage. Encore's sqldb package handles provisioning, connection pooling, and migrations automatically.

Declare the database in your tasks/tasks.go file. This creates a database named "tasks" with migrations in the ./migrations folder. Encore provisions PostgreSQL locally when you run the app, and in your cloud account when you deploy.

import "encore.dev/storage/sqldb"

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

Create the migrations folder and first migration. Migrations are SQL files that define your database schema. Encore runs them automatically in order, tracking which have been applied.

mkdir tasks/migrations

Create tasks/migrations/001_create_tasks.up.sql. The naming convention (number prefix, descriptive name, .up.sql suffix) tells Encore this is migration #1. Each migration should be idempotent and create the schema changes needed.

CREATE TABLE tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  completed BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

Now update tasks/tasks.go to use the database. The db.QueryRow and db.Query methods execute SQL with parameterized queries to prevent injection, and Scan maps columns to Go variables.

package tasks

import (
    "context"

    "encore.dev/beta/errs"
    "encore.dev/storage/sqldb"
)

type Task struct {
    ID          string `json:"id"`
    Title       string `json:"title"`
    Description string `json:"description"`
    Completed   bool   `json:"completed"`
}

type CreateTaskRequest struct {
    Title       string `json:"title"`
    Description string `json:"description,omitempty"`
}

type UpdateTaskRequest struct {
    Title       *string `json:"title,omitempty"`
    Description *string `json:"description,omitempty"`
    Completed   *bool   `json:"completed,omitempty"`
}

type ListTasksResponse struct {
    Tasks []*Task `json:"tasks"`
}

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

//encore:api public method=POST path=/tasks
func Create(ctx context.Context, req *CreateTaskRequest) (*Task, error) {
    task := &Task{}
    err := db.QueryRow(ctx,
        `INSERT INTO tasks (title, description)
         VALUES ($1, $2)
         RETURNING id, title, description, completed`,
        req.Title, req.Description,
    ).Scan(&task.ID, &task.Title, &task.Description, &task.Completed)
    if err != nil {
        return nil, err
    }
    return task, nil
}

//encore:api public method=GET path=/tasks/:id
func Get(ctx context.Context, id string) (*Task, error) {
    task := &Task{}
    err := db.QueryRow(ctx,
        `SELECT id, title, description, completed
         FROM tasks WHERE id = $1`, id,
    ).Scan(&task.ID, &task.Title, &task.Description, &task.Completed)
    if err != nil {
        return nil, &errs.Error{
            Code:    errs.NotFound,
            Message: "task not found",
        }
    }
    return task, nil
}

//encore:api public method=GET path=/tasks
func List(ctx context.Context) (*ListTasksResponse, error) {
    rows, err := db.Query(ctx,
        `SELECT id, title, description, completed
         FROM tasks ORDER BY created_at DESC`,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var tasks []*Task
    for rows.Next() {
        task := &Task{}
        if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Completed); err != nil {
            return nil, err
        }
        tasks = append(tasks, task)
    }
    return &ListTasksResponse{Tasks: tasks}, nil
}

//encore:api public method=PATCH path=/tasks/:id
func Update(ctx context.Context, id string, req *UpdateTaskRequest) (*Task, error) {
    // First check the task exists
    existing := &Task{}
    err := db.QueryRow(ctx,
        `SELECT id, title, description, completed
         FROM tasks WHERE id = $1`, id,
    ).Scan(&existing.ID, &existing.Title, &existing.Description, &existing.Completed)
    if err != nil {
        return nil, &errs.Error{
            Code:    errs.NotFound,
            Message: "task not found",
        }
    }

    // Apply updates
    title := existing.Title
    description := existing.Description
    completed := existing.Completed
    if req.Title != nil {
        title = *req.Title
    }
    if req.Description != nil {
        description = *req.Description
    }
    if req.Completed != nil {
        completed = *req.Completed
    }

    task := &Task{}
    err = db.QueryRow(ctx,
        `UPDATE tasks
         SET title = $1, description = $2, completed = $3
         WHERE id = $4
         RETURNING id, title, description, completed`,
        title, description, completed, id,
    ).Scan(&task.ID, &task.Title, &task.Description, &task.Completed)
    if err != nil {
        return nil, err
    }
    return task, nil
}

//encore:api public method=DELETE path=/tasks/:id
func Delete(ctx context.Context, id string) error {
    result, err := db.Exec(ctx,
        `DELETE FROM tasks WHERE id = $1`, id,
    )
    if err != nil {
        return err
    }
    if result.RowsAffected() == 0 {
        return &errs.Error{
            Code:    errs.NotFound,
            Message: "task not found",
        }
    }
    return nil
}

Restart the development server. Encore automatically provisions a local PostgreSQL database, runs your migrations, and connects your application. You can verify the database is working by creating tasks and checking they persist across restarts.

Error Handling

Encore provides built-in error types through the errs package that map to HTTP status codes. Using these ensures consistent error responses and proper status codes across your API.

import "encore.dev/beta/errs"

// 404 Not Found
return nil, &errs.Error{Code: errs.NotFound, Message: "task not found"}

// 400 Bad Request
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "title cannot be empty"}

// 401 Unauthorized
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid token"}

// 403 Forbidden
return nil, &errs.Error{Code: errs.PermissionDenied, Message: "not allowed"}

// 500 Internal Server Error
return nil, &errs.Error{Code: errs.Internal, Message: "something went wrong"}

For input validation, add checks at the start of your handlers. This catches bad data early and returns helpful error messages to clients.

//encore:api public method=POST path=/tasks
func Create(ctx context.Context, req *CreateTaskRequest) (*Task, error) {
    if req.Title == "" {
        return nil, &errs.Error{
            Code:    errs.InvalidArgument,
            Message: "title is required",
        }
    }
    if len(req.Title) > 200 {
        return nil, &errs.Error{
            Code:    errs.InvalidArgument,
            Message: "title must be 200 characters or less",
        }
    }
    // ... rest of handler
}

Adding Authentication

For protected endpoints, Encore provides an auth handler pattern. The auth handler runs before any endpoint marked with auth, validating credentials and extracting user information.

Create a new auth package. In Encore.go, services are just Go packages, so the auth handler lives in its own package directory.

mkdir auth

Create auth/auth.go. The auth handler receives request headers and returns user data that's available to all authenticated endpoints. If authentication fails, return an error.

package auth

import (
    "context"

    "encore.dev/beta/auth"
    "encore.dev/beta/errs"
)

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 := p.Authorization

    if token == "" {
        return "", nil, &errs.Error{
            Code:    errs.Unauthenticated,
            Message: "missing authorization header",
        }
    }

    // In production, verify the token (JWT, session, etc.)
    // For this example, we'll use a simple check
    if token == "Bearer valid-token" {
        return "user-123", &AuthData{UserID: "user-123"}, nil
    }

    return "", nil, &errs.Error{
        Code:    errs.Unauthenticated,
        Message: "invalid token",
    }
}

Now protect endpoints by changing public to auth in the annotation. Any request without valid credentials will be rejected before reaching your handler.

//encore:api auth method=POST path=/tasks
func Create(ctx context.Context, req *CreateTaskRequest) (*Task, error) {
    // Handler code
}

Access the authenticated user with auth.Data(). This returns the data your auth handler provided, letting you personalize responses or enforce ownership.

import "encore.dev/beta/auth"

//encore:api auth method=POST path=/tasks
func Create(ctx context.Context, req *CreateTaskRequest) (*Task, error) {
    userData := auth.Data().(*AuthData)
    log.Printf("User %s is creating a task", userData.UserID)
    // ...
}

Test an authenticated endpoint by passing the Authorization header.

# This will fail (no token)
curl -X POST http://localhost:4000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Protected task"}'

# This will succeed
curl -X POST http://localhost:4000/tasks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer valid-token" \
  -d '{"title": "Protected task"}'

Testing Your API

Create tasks/tasks_test.go. Encore lets you call your API endpoints directly in tests using the et package, without HTTP overhead. Tests run against real infrastructure (databases, etc.) so you're testing actual behavior.

package tasks

import (
    "context"
    "testing"

    "encore.dev/et"
)

func TestCreateAndGetTask(t *testing.T) {
    ctx := context.Background()

    // Create a task
    resp, err := et.NewTestEndpoint(Create).Call(ctx, &CreateTaskRequest{
        Title:       "Test task",
        Description: "A test description",
    })
    if err != nil {
        t.Fatal(err)
    }
    if resp.Title != "Test task" {
        t.Errorf("got title %q, want %q", resp.Title, "Test task")
    }
    if resp.Completed {
        t.Error("new task should not be completed")
    }

    // Fetch it back
    fetched, err := et.NewTestEndpoint(Get).Call(ctx, resp.ID)
    if err != nil {
        t.Fatal(err)
    }
    if fetched.ID != resp.ID {
        t.Errorf("got id %q, want %q", fetched.ID, resp.ID)
    }
}

func TestUpdateTask(t *testing.T) {
    ctx := context.Background()

    resp, err := et.NewTestEndpoint(Create).Call(ctx, &CreateTaskRequest{
        Title: "Update me",
    })
    if err != nil {
        t.Fatal(err)
    }

    completed := true
    updated, err := et.NewTestEndpoint(Update).Call(ctx, resp.ID, &UpdateTaskRequest{
        Completed: &completed,
    })
    if err != nil {
        t.Fatal(err)
    }
    if !updated.Completed {
        t.Error("task should be completed after update")
    }
}

func TestDeleteTask(t *testing.T) {
    ctx := context.Background()

    resp, err := et.NewTestEndpoint(Create).Call(ctx, &CreateTaskRequest{
        Title: "Delete me",
    })
    if err != nil {
        t.Fatal(err)
    }

    err = et.NewTestEndpoint(Delete).Call(ctx, resp.ID)
    if err != nil {
        t.Fatal(err)
    }

    // Verify the task is gone
    _, err = et.NewTestEndpoint(Get).Call(ctx, resp.ID)
    if err == nil {
        t.Error("expected error when fetching deleted task")
    }
}

Run tests with the Encore CLI. It spins up test databases and runs your test suite with full infrastructure support.

encore test ./tasks/...

Standard go test patterns all work, including table-driven tests and subtests. The et package adds the ability to call Encore endpoints directly with proper context and middleware.

Observability

Every request is automatically traced. Open the local dashboard at localhost:9400 to see distributed traces showing exactly what happened during each request, including database queries and their execution times.

Encore trace view

You don't need to add any instrumentation code. Encore captures traces for every API call, database query, and service-to-service communication automatically.

Deploying to Production

With Encore, deployment is a git push. Connect your app to Encore Cloud, then push to deploy. Encore handles the entire deployment pipeline automatically.

git add -A
git commit -m "Task API"
git push encore

Encore automatically:

  • Builds optimized Docker images
  • Provisions PostgreSQL on your cloud (AWS RDS or GCP Cloud SQL)
  • Sets up networking and security
  • Deploys with zero downtime

Check deployment status in the Encore Cloud dashboard. You'll see your architecture diagram, service catalog, and deployment history.

Encore Cloud dashboard

What You've Built

You now have a production-ready REST API with:

  • Type-safe endpoints with compile-time validation
  • PostgreSQL database with migrations
  • Error handling that maps to proper HTTP codes
  • Authentication support
  • Tests that call your handlers directly
  • Local development with automatic infrastructure
  • Distributed tracing for debugging

Next Steps

The patterns in this guide scale from side projects to production systems. Start simple, add complexity as needed.

Ready to build your next backend?

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