Build a Small REST API in Go: A Practical 2026 Guide

Build a Small REST API in Go: A Practical 2026 Guide

May 28, 2026

Go remains an excellent choice for small REST APIs because it gives you a pragmatic combination of speed, simplicity, and first-class tooling. The standard library’s net/http package is mature, production-ready, and flexible enough to power a surprising amount of API surface without adding much complexity. For many teams, that means less framework lock-in, fewer abstractions to debug, and easier onboarding for developers who already know HTTP fundamentals. Go’s module system also makes dependency management straightforward, while the broader ecosystem has solid routers, validation libraries, database drivers, and testing tools that fit cleanly into the language’s “small core, strong tooling” philosophy. (pkg.go.dev)

The 2025 Go Developer Survey, published in January 2026, reinforces that picture. Go developers reported very high satisfaction overall, while also asking for better help with best practices, idiomatic guidance, and improvements to built-in tooling and documentation. That matters directly for API work: the language is strong, but developers still want clearer guidance on how to structure services, handle errors, and use the standard library effectively. The same survey also noted that the go command’s help system and documentation remain pain points for some users, which is a good reminder that a small API should be built with conventions and clarity, not just with code that compiles. (go.dev)

In this guide, we’ll build a small CRUD REST API in Go with a design that starts minimal and scales sensibly. We’ll use the standard library first, then show where a lightweight router can improve ergonomics. We’ll cover request/response design, validation, persistence, testing, documentation, and deployment. The goal is not just to get an API running, but to give you a maintainable foundation you can keep using in 2026 and beyond.

1. Project Setup

A good Go API starts with a clean module and a deliberate folder layout. Install the latest stable Go toolchain from the official downloads page, then verify it with go version. For a new project, initialize a module at the repository root with go mod init, because the module path becomes the namespace for your packages and dependency graph. The official module docs recommend choosing a path that matches the repository or a name you control, and the go.mod file will keep builds reproducible as dependencies evolve. (go.dev)

For a small API, keep the structure shallow. A minimal layout is often enough:

myapi/
  go.mod
  main.go
  internal/
    handlers/
    store/
    models/
    validation/

If the service is tiny, you can even start with main.go plus a couple of internal packages and refactor later. The point is to separate transport concerns from business logic and persistence early, without over-engineering the project. That separation makes testing easier and helps keep the handler layer thin. Go’s module and package model is designed for this sort of incremental growth. (go.dev)

When should you use the standard library versus a lightweight router? Start with net/http if your API has only a handful of endpoints, simple path patterns, and no need for advanced middleware behavior. The standard library supports servers, handlers, request parsing, and response writing directly, and it is fully capable of serving production HTTP traffic. Add a router when route parameters, middleware chains, or cleaner path matching begin to clutter your handler code. At that point, the router should reduce friction, not become the architecture. (pkg.go.dev)

Project structure overview

2. Choosing the Stack

For a small REST API, the key choice is not “framework or no framework,” but “how much abstraction do I need right now?” net/http is the baseline: it is part of the standard library, widely documented, and production-grade. If you want to stay as close as possible to the platform, pair net/http with small helper packages and perhaps a thin router. That keeps the service easy to reason about and reduces dependency overhead. The Go standard library documentation explicitly describes net/http as providing both client and server implementations, and the Server type is designed for real deployment use. (pkg.go.dev)

Among routers and frameworks, chi is especially attractive for small services because it emphasizes lightweight, idiomatic composition. Its repository describes it as a “lightweight, idiomatic and composable router,” and its benchmark page is frequently cited in routing discussions. Gin remains one of the most visible options for REST APIs and microservices, with a long-standing emphasis on performance and a large ecosystem. Echo and Fiber are also popular and actively maintained; Echo presents itself as minimalist and high performance, while Fiber is Express-inspired and positioned around minimalism and developer friendliness. (github.com)

Here’s the practical trade-off for a small API:

  • net/http: best for minimal dependencies and maximum control.

  • chi: best when you want idiomatic routing and composability with little overhead.

  • Gin: best when you want a mature ecosystem and a familiar framework feel.

  • Echo: best when you want a minimalist framework with strong ergonomics.

  • Fiber: best when you like its Express-like design and are comfortable with its approach. (github.com)

Recent ecosystem survey feedback and framework benchmark pages still show Gin and chi as especially relevant options because they represent two strong poles of Go API development: one favors a broader framework experience, the other favors lean, idiomatic routing. In practice, both are reasonable for small APIs. If your team values explicitness and close alignment with Go’s style, chi is often the cleaner fit. If you prefer an all-in-one framework with more built-in convenience, Gin remains a sensible choice. (go.dev)

Router and framework comparison

3. Designing the API

Good API design starts before you write code. Decide what your resource is, what operations you support, and how clients will interact with it. For a practical example, consider a simple tasks API. A task might have an ID, title, description, completed flag, and timestamps. From there, define the CRUD surface:

  • GET /api/v1/tasks — list tasks

  • GET /api/v1/tasks/{id} — fetch one task

  • POST /api/v1/tasks — create a task

  • PUT /api/v1/tasks/{id} — replace a task

  • PATCH /api/v1/tasks/{id} — partially update a task

  • DELETE /api/v1/tasks/{id} — delete a task

Use HTTP methods consistently. GET should be safe and read-only. POST creates. PUT replaces a full resource representation. PATCH updates selected fields. DELETE removes the resource. For status codes, return 200 OK for reads and successful updates, 201 Created for creation, 204 No Content for successful deletes, 400 Bad Request for malformed input, 404 Not Found when an ID does not exist, and 409 Conflict when the operation violates a uniqueness or state constraint. These conventions make your API predictable to both humans and clients. (pkg.go.dev)

Versioning is worth deciding up front. For a small public API, path versioning is still the most straightforward pattern: /api/v1/.... It is easy to read, easy to document, and simple to route. For internal services, you can sometimes avoid explicit versioning by relying on deployments and backward-compatible changes, but once clients depend on your API, versioning becomes a contract-management tool. Be conservative: only introduce a new version when you need a breaking change in fields, behavior, or semantics. (owasp.org)

A clean JSON shape makes the API easier to integrate. A create request might look like this:

{
  "title": "Write API guide",
  "description": "Draft the first version",
  "completed": false
}

And a response might include server-managed fields:

{
  "id": "9f1d5c1d-3a0b-4f0c-8e9d-0b7c0f2dc0f1",
  "title": "Write API guide",
  "description": "Draft the first version",
  "completed": false,
  "created_at": "2026-05-28T10:15:00Z",
  "updated_at": "2026-05-28T10:15:00Z"
}

That separation is important: clients send only business input, while the server owns IDs and timestamps. It keeps your API simpler and reduces the chance of clients accidentally overwriting metadata.

4. Implementing Handlers

In Go, a handler should do as little as possible: read input, call a service or repository, and write a response. The standard library’s net/http package gives you everything you need to implement this pattern cleanly. Use http.NewServeMux or a router, then register handlers for each route and method. The http.Server type provides the knobs you need for deployment, while request parsing and response writing stay straightforward. (pkg.go.dev)

A useful pattern is to centralize JSON decoding and error handling. Decode with json.NewDecoder(r.Body) and call DisallowUnknownFields() if you want stricter input validation. Always check for decode errors, close the request body when appropriate, and return structured errors rather than ad hoc text. For example, if a request body includes an unexpected field, return 400 with a JSON object like:

{
  "error": {
    "code": "invalid_request",
    "message": "request body contains unknown field: priority"
  }
}

That kind of uniform error payload is easier to document and consume than plain strings. It also makes debugging simpler because clients can branch on stable error codes instead of brittle message text. A small API benefits enormously from consistency here. (pkg.go.dev)

Here is a compact handler example using net/http:

type TaskHandler struct {
	store TaskStore
}

func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := dec.Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
		return
	}

	task, err := h.store.Create(r.Context(), req)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "internal_error", "unable to create task")
		return
	}

	writeJSON(w, http.StatusCreated, task)
}

The same pattern applies to GET, PUT, PATCH, and DELETE. GET should retrieve by ID or list resources. PUT should replace the resource in a deterministic way. PATCH should update only the fields present in the request. DELETE should return 204 when the record is removed. Keep the handler layer boring; interesting logic belongs in the service or repository layer. That division makes the code easier to test and easier to swap later.

5. Validation and Data Modeling

Validation should happen as early as possible, but not in a way that makes your transport layer unreadable. Start by modeling separate request and response structs. Requests should contain only client-supplied fields; responses can include IDs, timestamps, and derived values. This prevents accidental coupling between input and persisted state. Go’s JSON tags and struct definitions make this separation clean and explicit. (pkg.go.dev)

For identifiers, both UUIDs and integer IDs are reasonable choices. UUIDs are a good fit when you want globally unique identifiers that are safe to generate outside the database, while integers are simpler if your data is strictly local and sequential IDs are acceptable. The important thing is consistency: choose one approach and document it. If you use UUIDs, represent them as strings in JSON and validate format at the boundary. If you use integers, ensure they are positive and handle parsing errors carefully.

Time handling should also be consistent. Use time.Time in Go and serialize timestamps in RFC 3339 / ISO 8601 format, typically in UTC. That gives clients predictable values and avoids timezone surprises. A response field such as created_at should always be emitted in a stable format, not as a locale-dependent string. If your API accepts timestamps in requests, document the accepted format explicitly and reject ambiguous values. (pkg.go.dev)

Validation can be manual or library-based. For a small API, manual validation is often enough:

func (r CreateTaskRequest) Validate() error {
	if strings.TrimSpace(r.Title) == "" {
		return errors.New("title is required")
	}
	if len(r.Title) > 200 {
		return errors.New("title must be 200 characters or fewer")
	}
	return nil
}

This is easy to reason about and test. If the API grows, you can add a validator library later, but don’t let validation become magical. Predictability matters more than cleverness. Also remember the OWASP API Security guidance: APIs are exposed to object-level authorization, broken authentication, misconfiguration, and resource-consumption risks, so validation should be complemented by authorization and input-size limits, not treated as a complete security solution. (owasp.org)

Request validation and data flow

6. Persistence Layer

For development and early testing, in-memory storage is the fastest way to move. A simple map protected by a mutex is often enough:

type MemoryTaskStore struct {
	mu    sync.RWMutex
	tasks map[string]Task
}

This gets you moving quickly and keeps the API self-contained. It is also a good fit for unit tests because it removes database setup from the critical path. But in-memory storage is ephemeral, so it is not a production persistence strategy. When the process exits, data disappears. That is fine for a prototype, not for a real service.

The repository pattern is the cleanest path to swapping storage later. Define an interface in your application layer:

type TaskStore interface {
	Create(ctx context.Context, req CreateTaskRequest) (Task, error)
	Get(ctx context.Context, id string) (Task, error)
	List(ctx context.Context) ([]Task, error)
	Update(ctx context.Context, id string, req UpdateTaskRequest) (Task, error)
	Delete(ctx context.Context, id string) error
}

Then implement that interface with an in-memory store, a PostgreSQL store, or a SQLite store. Because the handlers depend on the interface rather than the concrete database, you can change persistence without rewriting the HTTP layer. That is the main payoff of the repository pattern: portability with limited ceremony. (pkg.go.dev)

When you move to PostgreSQL or SQLite, think carefully about transaction boundaries and concurrency safety. database/sql automatically manages connections and maintains a pool of idle connections, and it notes that connection-specific state is reliably observed inside a transaction or connection. That means you should keep multi-step writes inside a transaction when atomicity matters, especially if one API call updates multiple tables or records. For read-heavy endpoints, transactions can be unnecessary overhead; use them where correctness requires them. (pkg.go.dev)

Concurrency safety matters even more when handlers run in parallel. In-memory maps must be guarded with locks; database drivers and database/sql handle pooling, but your application-level invariants still need protection. If two requests can create or update the same logical resource at once, enforce rules in the database and in the application. The safest design is to treat the repository as the source of truth for persistence semantics and keep business rules above it.

7. Testing and Quality

Go’s testing story is one of its biggest strengths. Use table-driven tests for business logic, and use net/http/httptest for handler and route tests. The testing package and the standard HTTP test utilities let you exercise your API end-to-end without starting a real network listener. That makes tests fast, deterministic, and easy to run in CI. (go.dev)

A good pattern is to test each layer separately:

  • Unit tests for validation and service logic.

  • HTTP tests for status codes, response bodies, and route wiring.

  • Repository tests for persistence behavior.

  • Contract checks for JSON shape and error format.

Table-driven tests are especially useful in Go because they make edge cases explicit:

func TestCreateTaskValidate(t *testing.T) {
	tests := []struct {
		name    string
		req     CreateTaskRequest
		wantErr bool
	}{
		{"valid", CreateTaskRequest{Title: "A"}, false},
		{"missing title", CreateTaskRequest{}, true},
		{"too long", CreateTaskRequest{Title: strings.Repeat("x", 201)}, true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := tt.req.Validate()
			if (err != nil) != tt.wantErr {
				t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
			}
		})
	}
}

For handler tests, use httptest.NewRequest and httptest.NewRecorder, then assert on status code, headers, and body. If you have a repository interface, use fakes or mocks to isolate the handler layer from persistence. That keeps tests focused and makes failure modes easier to understand.

Quality should also include formatting and linting. gofmt is non-negotiable; it keeps code style uniform across the entire codebase. Add go test ./... to CI, and consider go vet and a linter such as golangci-lint for broader static checks. Basic contract checks are also worth adding: verify that every error payload contains a stable error code, that timestamps are RFC 3339, and that missing resources return 404 instead of 200 with an empty body. Those checks save time later when clients begin integrating.

8. Documentation and Developer Experience

Developer experience matters almost as much as code correctness, especially for APIs. Good documentation reduces integration time, lowers support burden, and makes your service easier to evolve. At a minimum, provide a README with endpoint examples, authentication notes, configuration instructions, and sample requests and responses. Include curl snippets so developers can try the API without needing to read source code first. (owasp.org)

You can generate OpenAPI documentation or hand-write it. For a small API, either approach works as long as the output is accurate and maintained. Generated docs are useful when your code is the source of truth and you want less duplication. Hand-written docs are useful when you want to present a polished contract to external consumers. In both cases, document the same essentials: paths, methods, request schemas, response schemas, status codes, and error formats. That clarity helps clients automate integration and improves discoverability. (owasp.org)

Clear error payloads are a big part of good API documentation. Don’t bury behavior in prose; specify it concretely. For example:

{
  "error": {
    "code": "not_found",
    "message": "task not found",
    "details": {
      "resource": "task",
      "id": "123"
    }
  }
}

This makes it obvious how clients should react. OWASP’s API guidance emphasizes that APIs are common targets and that security and inventory clarity matter, so docs should also explain authorization expectations, rate limits, and unsupported behaviors. If your API requires a bearer token or enforces per-user ownership, say so plainly. Don’t leave security behavior to inference. (owasp.org)

9. Deployment and Next Steps

For deployment, containerize the API with a minimal image and a simple startup command. A multi-stage Docker build is usually enough: compile the binary in one stage, copy it into a small runtime image in the next. Keep configuration in environment variables so the same container image can run in development, staging, and production. This fits Go’s deployment model well, especially for containerized Linux workloads, which the Go survey shows are the dominant deployment environment for many Go developers. (go.dev)

A production-ready Go service should also include structured logging and basic metrics. Logs should have request IDs, route names, status codes, latency, and error codes. Metrics should track request rate, error rate, and latency distributions at a minimum. That observability layer is what lets you diagnose problems after deployment instead of guessing. From there, add health endpoints such as /healthz and /readyz so orchestration systems can distinguish between “process is alive” and “service can actually serve traffic.”

The next hardening steps are straightforward but important: add authentication, authorization, request size limits, rate limiting, and timeouts. OWASP’s API Security Top 10 continues to emphasize broken object-level authorization, broken authentication, unrestricted resource consumption, security misconfiguration, and unsafe consumption patterns, all of which are directly relevant even to a small CRUD API. A service may start small, but if it exposes data to real users, it needs the same defensive thinking as a larger system. (owasp.org)

A practical deployment checklist looks like this:

  • Set PORT, DATABASE_URL, and secret values via environment variables.

  • Use context.Context timeouts for DB and external calls.

  • Add graceful shutdown handling with http.Server.Shutdown.

  • Expose metrics and logs early.

  • Enforce auth and authorization before production exposure.

  • Review API behavior against OWASP guidance before launch.

Conclusion

A small REST API in Go can stay simple without becoming fragile. The best approach is to begin with the standard library, introduce a router only when it buys you real clarity, and keep transport, validation, and persistence concerns separated. Go’s ecosystem is strong precisely because it gives you a stable core, good tooling, and enough framework choice to fit different teams without forcing a single style. Recent survey feedback confirms that developers value Go’s simplicity and built-in tooling, while also wanting better idiomatic guidance and help systems. That is exactly why a pragmatic, opinionated architecture helps. (go.dev)

If you follow the patterns in this guide—clear resource design, consistent JSON, a repository layer, table-driven tests, and documented error contracts—you end up with more than a toy service. You get a compact foundation that can move from in-memory storage to PostgreSQL or SQLite, from local development to containers, and from internal use to production with only incremental changes. That is the real advantage of Go for small APIs: not just speed, but maintainable simplicity.

References