ScreenshotNeo

BlogHow-to

How to Build an API with Go

Build a small JSON API in Go with runnable code, standard-library routing, error handling, and practical next steps for production.

By the ScreenshotNeo team30 September 20263 min read

How to Build an API with Go

To build an API with Go, create a module, define resource routes, decode and validate JSON, and return JSON with appropriate HTTP status codes. For a small service, Go 1.22 and later include method-aware routes and path wildcards in the standard library’s net/http. This walkthrough builds a runnable in-memory API using those features. The storage is intentionally temporary; a real service usually persists data in a database.

The Go project’s tutorials also cover modules, JSON, relational databases, and a REST API tutorial using Gin. Gin is a reasonable choice when you want a framework’s additional routing and handler features. The standard library and a framework are both valid choices; the right fit depends on what the application needs.

1. Choose your router

Go 1.22 added HTTP method matching and wildcard path segments to http.ServeMux. A pattern such as GET /albums/{id} matches a request and exposes the segment through r.PathValue("id"). This means many small APIs can use the standard library without a routing dependency. Frameworks such as Gin remain suitable for projects that need additional routing features or abstractions.

Choice Good fit when Trade-off
net/http (Go 1.22+) You want standard HTTP handlers and straightforward method/path routing. You assemble middleware and other application structure from the standard library or your own packages.
Gin You want to use a framework and its handler and routing conventions. You add a dependency and follow the framework’s APIs.

The official Go routing blog describes the standard-library additions as “one fewer dependency for many projects” and also says third-party frameworks remain a fine choice for advanced routing needs. The Go 1.22 release notes document the routing changes. [Go routing enhancements; Go 1.22 release notes]

2. Create a Go module

Install Go 1.22 or later for the standard-library route patterns in this example. Create a directory and initialize a module:

mkdir albums-api
cd albums-api
go mod init example.com/albums-api

A Go module records the module path and dependency versions. This example uses only the standard library, so it needs no third-party package. Create a file named main.go with the complete program below.

3. Write the API

package main

import (
	"encoding/json"
	"errors"
	"io"
	"log"
	"net/http"
	"strings"
	"sync"
)

type Album struct {
	ID     string  `json:"id"`
	Title  string  `json:"title"`
	Artist string  `json:"artist"`
	Price  float64 `json:"price"`
}

type API struct {
	mu     sync.RWMutex
	albums map[string]Album
}

func main() {
	api := &API{albums: map[string]Album{
		"1": {ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99},
		"2": {ID: "2", Title: "Jeru", Artist: "Gerry Mulligan", Price: 17.99},
		"3": {ID: "3", Title: "Sarah Vaughan", Artist: "Sarah Vaughan", Price: 39.99},
	}}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /albums", api.listAlbums)
	mux.HandleFunc("POST /albums", api.createAlbum)
	mux.HandleFunc("GET /albums/{id}", api.getAlbum)

	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}
	log.Printf("listening on %s", server.Addr)
	log.Fatal(server.ListenAndServe())
}

func (api *API) listAlbums(w http.ResponseWriter, r *http.Request) {
	api.mu.RLock()
	defer api.mu.RUnlock()

	albums := make([]Album, 0, len(api.albums))
	for _, album := range api.albums {
		albums = append(albums, album)
	}
	writeJSON(w, http.StatusOK, albums)
}

func (api *API) getAlbum(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	api.mu.RLock()
	album, ok := api.albums[id]
	api.mu.RUnlock()
	if !ok {
		writeError(w, http.StatusNotFound, "album not found")
		return
	}
	writeJSON(w, http.StatusOK, album)
}

func (api *API) createAlbum(w http.ResponseWriter, r *http.Request) {
	var album Album
	if err := decodeJSON(w, r, &album); err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	album.ID = strings.TrimSpace(album.ID)
	album.Title = strings.TrimSpace(album.Title)
	album.Artist = strings.TrimSpace(album.Artist)
	if album.ID == "" || album.Title == "" || album.Artist == "" {
		writeError(w, http.StatusBadRequest, "id, title, and artist are required")
		return
	}

	api.mu.Lock()
	defer api.mu.Unlock()
	if _, exists := api.albums[album.ID]; exists {
		writeError(w, http.StatusConflict, "album id already exists")
		return
	}
	api.albums[album.ID] = album
	w.Header().Set("Location", "/albums/"+album.ID)
	writeJSON(w, http.StatusCreated, album)
}

func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	if contentType := r.Header.Get("Content-Type"); contentType != "" &&
		!strings.HasPrefix(contentType, "application/json") {
		return errors.New("Content-Type must be application/json")
	}
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	defer r.Body.Close()
	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(dst); err != nil {
		return errors.New("request body must contain valid JSON: " + err.Error())
	}
	var extra any
	if err := decoder.Decode(&extra); err != io.EOF {
		return errors.New("request body must contain exactly one JSON value")
	}
	return nil
}

func writeJSON(w http.ResponseWriter, status int, value any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(value); err != nil {
		log.Printf("encode response: %v", err)
	}
}

func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, map[string]string{"error": message})
}

The handlers use encoding/json to encode and decode JSON. The struct tags set the public field names. The mutex protects the in-memory map because HTTP requests can be handled concurrently. The decoder caps request bodies at 1 MiB, rejects unknown fields, and rejects a second JSON value. These checks make the example’s input behavior easier to reason about; adjust validation and body limits to fit your API contract.

An API connects a client request to application logic and returns a structured response.
An API connects a client request to application logic and returns a structured response.

4. Run it and make requests

Start the server from the module directory:

go run .

In another terminal, list the albums, fetch one, create a record, and try a missing ID:

curl -i http://localhost:8080/albums
curl -i http://localhost:8080/albums/1
curl -i -X POST http://localhost:8080/albums \
  -H 'Content-Type: application/json' \
  -d '{"id":"4","title":"Kind of Blue","artist":"Miles Davis","price":49.99}'
curl -i http://localhost:8080/albums/missing

The list and fetch requests return 200 OK. A valid create returns 201 Created with a Location header. A malformed or invalid create returns 400 Bad Request; an unknown album returns 404 Not Found; a duplicate ID returns 409 Conflict. These choices make the outcome visible to clients without requiring them to parse a success-shaped response for every case.

5. Understand the API contract and route behavior

Method and path Purpose Successful response
GET /albums List records 200, JSON array
GET /albums/{id} Fetch one record 200, JSON object
POST /albums Create a record 201, created object and Location

ServeMux patterns match both the HTTP method and the path. An unsupported method does not match a route registered for another method, and the mux handles the unmatched request. A wildcard such as {id} captures one path segment; it is not a substitute for validating the ID’s format. If IDs have a defined format, check it before looking them up or storing them.

For predictable APIs, document whether omitted fields differ from empty fields, whether unknown JSON keys are accepted, and what duplicate creates mean. This sample rejects unknown keys and requires non-empty ID, title, and artist. It does not enforce a positive price or a maximum string length; add domain validation where those rules matter. If clients need to update records, define separate PUT or PATCH routes and spell out replacement versus partial-update semantics.

6. Replace the in-memory map for a real service

The sample data disappears when the process stops, and the map is not shared between multiple server instances. The official Gin REST API tutorial also uses in-memory data for its example and says a more typical API would interact with a database. Go’s tutorial index includes separate material on accessing a relational database. [Developing a RESTful API with Go and Gin; Accessing a relational database]

Move persistence behind a small repository interface or package so handlers do not depend directly on a map or database driver. Select a database and driver based on your data model and operational needs; those choices are outside this minimal example. Decide how the API handles database errors, transactions, duplicate keys, and concurrent writes. Keep credentials out of source code and use the deployment environment’s secret-management approach.

7. Adapt the routing choice

The official Go tutorial demonstrates a REST API using Gin with list, create, and fetch-by-ID endpoints. To start a Gin project, initialize a module and add Gin as shown by its tutorial:

go mod init example.com/albums-api
go get github.com/gin-gonic/gin

Gin’s tutorial demonstrates JSON responses and an album resource; its sample is intentionally in-memory. Follow the current official guide for the complete framework-specific handler setup and imports rather than mixing its APIs into the standard-library example. [Gin REST API tutorial]

Choose the standard mux when method-and-path routing is enough and you prefer the standard library. Choose a framework when its routing or other abstractions fit the application. The sources here establish those capabilities and options, but do not establish a universal productivity, popularity, or performance winner.

8. From tutorial to production

This code is a learning example, not a complete production deployment recipe. Before serving real clients, make decisions in these areas:

  • Persistence: replace the process-local map and define database error handling.
  • Input contract: validate every field and size according to the resource’s rules; decide how to handle unknown fields and content types.
  • Authentication and authorization: establish who may call each route and which records they may access.
  • Request limits: set body and time limits appropriate to the service, and define how clients learn about throttling if you apply it.
  • Lifecycle: plan startup, shutdown, configuration, and how the service reports failures.
  • Operations: decide what request and error information to log, how to observe service health, and how to deploy and update it.

Those areas require choices that depend on the application and hosting environment; the Go routing and tutorial sources cited above do not prescribe a complete security or operations architecture. Avoid treating the example’s mutex, body limit, or status codes as a substitute for a system-specific review.

9. Troubleshooting common problems

Symptom Likely cause What to check
Compile error mentioning method patterns or PathValue Go toolchain is older than 1.22. Check go version; use Go 1.22 or later, or use a router pattern supported by your version.
Request returns 404 Path does not match a registered route, ID is absent, or the requested record does not exist. Check spelling, path segments, and whether the ID exists. The example returns JSON for a missing album.
Request returns 405 or another unmatched-route response The route exists for a different HTTP method. Use the method registered for that path, or register the intended method and handler.
Create returns 400 JSON is malformed, has an unknown field, is larger than the limit, has trailing JSON, uses a non-JSON content type, or misses required values. Send one JSON object, set Content-Type: application/json, and include non-empty ID, title, and artist.
Create returns 409 The album ID is already present. Choose a new ID or define a separate update operation.
Port 8080 is unavailable Another process is listening on that address. Stop the other process or change Addr and send requests to the new port.
Created data disappears after restart The example stores records only in memory. Persist records in a database or another durable store.

10. Performance, reliability, and cost considerations

This example makes no performance claim. The map provides simple in-process access, but it is not durable, shared across instances, or a database. A mutex keeps concurrent map access safe; a production persistence layer needs its own concurrency and transaction behavior. Measure the actual service under representative requests before choosing optimizations.

Reliability depends on more than route matching: persistence, timeouts, failure handling, deployment, and monitoring all affect whether clients can use an API consistently. Set those policies for the service’s dependencies and environment. Avoid logging secrets or full sensitive request bodies. A database or hosting bill depends on the selected providers and workload; neither is specified by this tutorial, so there is no defensible universal cost estimate.

11. See what your API returns in a browser

Once an API serves a page or a frontend consumes its responses, a screenshot can help inspect the rendered result. A browser-based capture also makes it possible to see whether a consent banner or overlay obscures the page.

Screenshot cleanup removes common overlays before the page image is captured.
Screenshot cleanup removes common overlays before the page image is captured.

Or skip the browser setup

For a screenshot of a page that uses your API, ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request returns an image or PDF. See the API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up free for ScreenshotNeo.

FAQ

Does this example implement a complete REST API?

It demonstrates listing, creating, and fetching one resource. It does not define update or delete behavior, authentication, database persistence, or an API versioning policy.

Can I use the standard library with a Go version before 1.22?

Yes, but this example’s method-aware wildcard patterns and Request.PathValue use Go 1.22 additions. Use routing patterns supported by your installed version or choose a compatible router.

Where should I go next in the official Go material?

Continue with the official tutorials on modules, JSON, database access, and the Gin REST API tutorial, depending on which part of the service you want to develop next. [Go tutorials]