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.
A task management API with:
We'll use Encore.go for this tutorial because it removes most infrastructure setup, but the patterns apply to any Go backend.
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.

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:
//encore:api to declare it as an endpoint with an HTTP method and pathTest it by making a request. The endpoint automatically parses the path parameter and returns JSON.
curl http://localhost:4000/hello/world
# {"message":"Hello, world!"}
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>
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.
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
}
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"}'
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.
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.

You don't need to add any instrumentation code. Encore captures traces for every API call, database query, and service-to-service communication automatically.
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:
Check deployment status in the Encore Cloud dashboard. You'll see your architecture diagram, service catalog, and deployment history.

You now have a production-ready REST API with:
The patterns in this guide scale from side projects to production systems. Start simple, add complexity as needed.