In the previous tutorial, you learned advanced error handling patterns. Now it is time to build something real — an HTTP server.
Go has a powerful HTTP server in the standard library. No framework needed. The net/http package gives you everything for building web servers and REST APIs. Many production Go services use only the standard library.
Your First HTTP Server
package main
import (
"fmt"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
}
func main() {
http.HandleFunc("/hello", helloHandler)
fmt.Println("Server starting on :8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Println("Server error:", err)
}
}
Run it:
go run main.go
Test it:
curl http://localhost:8080/hello
# Hello, World!
That is it. Three lines of setup. The handler function takes two parameters:
http.ResponseWriter— write the response*http.Request— read the request (method, headers, body, URL)
HTTP Methods and Status Codes
Check the HTTP method to handle different request types:
package main
import (
"fmt"
"net/http"
)
func userHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
fmt.Fprintf(w, "Getting users")
case http.MethodPost:
fmt.Fprintf(w, "Creating a user")
case http.MethodPut:
fmt.Fprintf(w, "Updating a user")
case http.MethodDelete:
fmt.Fprintf(w, "Deleting a user")
default:
w.WriteHeader(http.StatusMethodNotAllowed)
fmt.Fprintf(w, "Method not allowed")
}
}
func main() {
http.HandleFunc("/users", userHandler)
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", nil)
}
Use constants for HTTP methods and status codes:
http.MethodGet // "GET"
http.MethodPost // "POST"
http.MethodPut // "PUT"
http.MethodDelete // "DELETE"
http.StatusOK // 200
http.StatusCreated // 201
http.StatusBadRequest // 400
http.StatusNotFound // 404
http.StatusInternalServerError // 500
Go 1.22+ Routing Patterns
Go 1.22 added built-in routing patterns with HTTP method matching and path parameters. No external router needed:
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Method + path pattern (Go 1.22+)
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", mux)
}
func listUsers(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "List all users")
}
func createUser(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Create a user")
}
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // Get path parameter (Go 1.22+)
fmt.Fprintf(w, "Get user: %s", id)
}
func updateUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "Update user: %s", id)
}
func deleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "Delete user: %s", id)
}
Test it:
curl http://localhost:8080/users
# List all users
curl http://localhost:8080/users/42
# Get user: 42
curl -X DELETE http://localhost:8080/users/42
# Delete user: 42
The {id} syntax creates a path parameter. Read it with r.PathValue("id"). The method prefix (GET, POST, etc.) restricts the route to that method.
Query Parameters
Read query parameters from the URL:
package main
import (
"fmt"
"net/http"
"strconv"
)
func searchHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
keyword := query.Get("q")
page := query.Get("page")
limit := query.Get("limit")
// Set defaults
if page == "" {
page = "1"
}
if limit == "" {
limit = "10"
}
pageNum, _ := strconv.Atoi(page)
limitNum, _ := strconv.Atoi(limit)
fmt.Fprintf(w, "Search: q=%s, page=%d, limit=%d", keyword, pageNum, limitNum)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /search", searchHandler)
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", mux)
}
Test it:
curl "http://localhost:8080/search?q=golang&page=2&limit=20"
# Search: q=golang, page=2, limit=20
JSON Responses
Most APIs return JSON. Use encoding/json to convert Go structs to JSON:
package main
import (
"encoding/json"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func listUsersHandler(w http.ResponseWriter, r *http.Request) {
users := []User{
{ID: 1, Name: "Alex", Email: "alex@example.com"},
{ID: 2, Name: "Sam", Email: "sam@example.com"},
{ID: 3, Name: "Jordan", Email: "jordan@example.com"},
}
writeJSON(w, http.StatusOK, users)
}
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Simulated lookup
if id == "1" {
user := User{ID: 1, Name: "Alex", Email: "alex@example.com"}
writeJSON(w, http.StatusOK, user)
return
}
writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "user not found"})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users", listUsersHandler)
mux.HandleFunc("GET /users/{id}", getUserHandler)
http.ListenAndServe(":8080", mux)
}
Test it:
curl http://localhost:8080/users
# [{"id":1,"name":"Alex","email":"alex@example.com"},{"id":2,"name":"Sam","email":"sam@example.com"},{"id":3,"name":"Jordan","email":"jordan@example.com"}]
curl http://localhost:8080/users/99
# {"error":"user not found"}
Struct tags control JSON field names:
`json:"name"`— field appears as “name” in JSON`json:"name,omitempty"`— field is omitted if empty`json:"-"`— field is never included in JSON
Reading JSON Request Body
Use json.NewDecoder to parse JSON from the request body:
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
// Decode JSON body
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid JSON: " + err.Error(),
})
return
}
// Validate
if req.Name == "" || req.Email == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "name and email are required",
})
return
}
// Create user (simulated)
user := User{
ID: 1,
Name: req.Name,
Email: req.Email,
}
fmt.Printf("Created user: %+v\n", user)
writeJSON(w, http.StatusCreated, user)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /users", createUserHandler)
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", mux)
}
Test it:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"Alex","email":"alex@example.com"}'
# {"id":1,"name":"Alex","email":"alex@example.com"}
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":""}'
# {"error":"name and email are required"}
Static File Serving
Serve static files (HTML, CSS, images) from a directory:
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Serve files from the ./static directory
fs := http.FileServer(http.Dir("./static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
// API routes
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"status":"ok"}`)
})
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", mux)
}
Files in ./static/ are served at /static/. For example, ./static/style.css is available at http://localhost:8080/static/style.css.
A Complete REST API Example
Here is a complete in-memory CRUD API for notes:
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
)
type Note struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
type CreateNoteRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
type NoteStore struct {
mu sync.RWMutex
notes map[int]Note
nextID int
}
func NewNoteStore() *NoteStore {
return &NoteStore{
notes: make(map[int]Note),
nextID: 1,
}
}
func (s *NoteStore) Create(title, content string) Note {
s.mu.Lock()
defer s.mu.Unlock()
note := Note{
ID: s.nextID,
Title: title,
Content: content,
CreatedAt: time.Now(),
}
s.notes[s.nextID] = note
s.nextID++
return note
}
func (s *NoteStore) GetAll() []Note {
s.mu.RLock()
defer s.mu.RUnlock()
notes := make([]Note, 0, len(s.notes))
for _, note := range s.notes {
notes = append(notes, note)
}
return notes
}
func (s *NoteStore) GetByID(id int) (Note, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
note, ok := s.notes[id]
return note, ok
}
func (s *NoteStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.notes[id]
if ok {
delete(s.notes, id)
}
return ok
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func main() {
fmt.Println("=== GO-15: net/http REST API ===")
fmt.Println()
store := NewNoteStore()
// Add some sample data
store.Create("Learn Go", "Start with the basics and build up to concurrency.")
store.Create("Build an API", "Use net/http to create REST endpoints.")
mux := http.NewServeMux()
// List all notes
mux.HandleFunc("GET /api/notes", func(w http.ResponseWriter, r *http.Request) {
notes := store.GetAll()
writeJSON(w, http.StatusOK, notes)
})
// Get a note by ID
mux.HandleFunc("GET /api/notes/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
note, ok := store.GetByID(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "note not found"})
return
}
writeJSON(w, http.StatusOK, note)
})
// Create a note
mux.HandleFunc("POST /api/notes", func(w http.ResponseWriter, r *http.Request) {
var req CreateNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
note := store.Create(req.Title, req.Content)
writeJSON(w, http.StatusCreated, note)
})
// Delete a note
mux.HandleFunc("DELETE /api/notes/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if !store.Delete(id) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "note not found"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "note deleted"})
})
// Health check
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
fmt.Println("Server starting on :8080")
fmt.Println("Try: curl http://localhost:8080/api/notes")
err := http.ListenAndServe(":8080", mux)
if err != nil {
fmt.Println("Server error:", err)
}
}
Test it:
# List notes
curl http://localhost:8080/api/notes
# Get note by ID
curl http://localhost:8080/api/notes/1
# Create a note
curl -X POST http://localhost:8080/api/notes \
-H "Content-Type: application/json" \
-d '{"title":"New Note","content":"Some content"}'
# Delete a note
curl -X DELETE http://localhost:8080/api/notes/1
# Health check
curl http://localhost:8080/api/health
This API is thread-safe thanks to sync.RWMutex in the store. Multiple goroutines (HTTP requests) can read notes at the same time, but writes are serialized.
Common Mistakes
1. Forgetting to set Content-Type for JSON responses.
// Bad — browser may not parse as JSON
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
// Good — always set Content-Type
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
2. Writing headers after WriteHeader or Write.
// Bad — header is already sent
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json") // Too late!
// Good — set headers BEFORE WriteHeader
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
3. Not closing the request body.
// Bad — body is not closed
json.NewDecoder(r.Body).Decode(&req)
// Good — close the body when done
defer r.Body.Close()
json.NewDecoder(r.Body).Decode(&req)
In HTTP handlers, the server closes the body automatically, so this is less critical. But it is good practice, especially in HTTP clients.
Source Code
You can find the complete source code for this tutorial on GitHub:
Related Articles
- Go Tutorial #14: Error Handling Patterns — Advanced error handling
- Go Tutorial #13: Select, Context, and Patterns — Concurrency patterns used in servers
- Go Tutorial #16: Building REST APIs with Gin — Gin framework for web APIs
- Go Cheat Sheet — Quick reference for Go syntax
What’s Next?
In the next tutorial, Go Tutorial #16: Building REST APIs with Gin, you will learn:
- What Gin is and why it is the most popular Go web framework
- Gin vs Echo vs Fiber vs Chi
- Route groups, request binding, and middleware
- Building a complete REST API with Gin
This is part 15 of the Go Tutorial series. Follow along to learn Go from scratch.