Build a Task Manager REST API in Go
Build a full CRUD REST API in Go using only the standard library.
Introduction
30 Second Summary
Every app you use daily relies on an API behind the scenes. When you check off a task in your to-do app, an API receives that update, processes it, and stores it. These APIs are the backbone of modern software.
In this project, you will build a Task Manager REST API from scratch using Go and its standard library. You will create endpoints that let you create, read, update, and delete tasks, all running on your local machine.
What You'll Build
A complete REST API for managing tasks, built entirely with Go's standard library to demonstrate why Go dominates cloud-native backend development.
By the end of this project, you'll have:
- A fully functional REST API with CRUD endpoints (create, read, update, delete) running on localhost.
- A logging middleware that tracks every incoming request with method, path, and response time.
- A concurrency-safe in-memory data store protected by Go's sync.Mutex, plus a test file verifying your API works correctly.
- Secret Mission: Add task filtering by status and a health check endpoint to make your API production-ready.
Are there any prerequisites?
You should have basic programming experience in any language. If you understand variables, functions, and loops, you have everything you need to get started with Go.
Before We Start
Go powers the backend of the modern internet. Docker, Kubernetes, and Terraform are all written in Go.
In this project, you'll learn why by building a real REST API from scratch. Before you can write any Go code, you need the Go toolchain and a code editor installed on your machine.
In this step, get ready to:
- Install Go 1.26.3 and verify it runs.
- Set up VS Code with the Go extension.
- Create your project folder and confirm you can run Go code.
Download and install Go
Go is the programming language you'll use for this entire project. You need the Go toolchain installed so you can compile and run Go programs.
- Open your browser and go to go.dev/dl.
- Click the go1.26.3.windows-amd64.msi download link under the **Featured downloads** section for Windows.
- Run the downloaded MSI installer.
- Accept all the default options and click Next through each screen until the installer finishes.
What is the Go toolchain?
The installer puts the Go compiler, standard library, and a set of command-line tools on your machine. Together these are called the Go toolchain. Once installed, you can run commands like go run and go test from any terminal.
Now verify that Go is installed and accessible from your terminal.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Check your Go version by running this command:
go version
✔️ I see go1.26 or higher
You should see output like go version go1.26.3 windows/amd64. Go is ready to use.
ⓧ I see an older version
If your version is lower than 1.26, you need to upgrade. Download the latest MSI installer from go.dev/dl and run it. The new installer replaces your existing Go installation.
- Close PowerShell after the installer finishes.
- Reopen PowerShell and run go version again to confirm the upgrade.
ⓧ 'go' is not recognized
If you see an error like The term 'go' is not recognized as the name of a cmdlet, it means your terminal cannot find the Go binary. This usually happens because PowerShell was opened before the installer finished.
- Close PowerShell completely.
- Reopen PowerShell using the Windows search bar.
- Run go version again.
If it still does not work, the MSI installer may not have completed. Re-download and run the installer from go.dev/dl.
Install VS Code and the Go extension
VS Code is the code editor you'll use to write, save, and run your Go files. The Go extension adds syntax highlighting, auto-completion, and error checking.
- Open your browser and go to code.visualstudio.com.
- Click the download button for Windows and run the installer.
- Accept all the default options and complete the installation.
Now open VS Code and install the Go extension.
- Press the Windows key to open your search bar.
- Type VS Code and press Enter to open it.
- Click the Extensions icon in the left sidebar (it looks like four small squares).
- Type Go in the search bar at the top of the Extensions panel.
- Find the extension called Go by the Go Team at Google (extension ID golang.go).
- Click Install.
What does the Go extension do?
The Go extension connects VS Code to the Go toolchain you just installed. It highlights syntax errors as you type, auto-formats your code on save, and provides intelligent code suggestions.
Create your project folder and run Hello World
Every Go project lives inside a module. A module is a folder that contains a go.mod file, which tells Go the name of your project and which version of Go it uses.
- Open the VS Code terminal by pressing Ctrl+` (the backtick key, usually above Tab).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir task-manager
cd task-manager
- Initialize a Go module by running:
go mod init task-manager
What is a Go module?
A Go module groups all of your project's code under one name. The go mod init command creates a go.mod file that tracks your module name and Go version. Later, if you add external packages, they get recorded here too.
Now open this folder in VS Code so you can create files inside it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the task-manager folder, and click Select Folder.
- If VS Code asks whether you trust the authors of this folder, click Yes, I trust the authors.
VS Code reloads with your project folder open. Now create the main Go file.
- In the Explorer sidebar on the left, hover over the TASK-MANAGER heading.
- Click the New File icon (a page with a plus sign).
- Type main.go and press Enter.
- Paste the following code into main.go:
package main
import "fmt"
func main() {
// Print a greeting to confirm Go runs correctly
fmt.Println("Hello, World!")
}
What does this code do?
Every Go program starts with package main. This tells Go that this file is the entry point for your application.
The import "fmt" line pulls in Go's formatting package, which includes the Println function. The func main() block is where your program starts executing.
- Save main.go by pressing Ctrl+S.
- Open the terminal again by pressing Ctrl+`.
- Run your program by entering this command:
go run .
- You should see Hello, World! printed in the terminal.
Don't see Hello, World! in the terminal?
- Make sure you saved main.go before running. Unsaved changes won't be compiled.
- Check that your terminal is in the task-manager folder. Run pwd to confirm.
- Verify that the file is named exactly main.go (not main.go.txt or Main.go).
Still stuck? Help me debug why go run . is not printing Hello, World!
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go and you're ready to move on.
ⓧ I'd like to double check the full code
Here is the complete main.go file at this point:
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
Your environment is ready. You have Go, VS Code, and a running project. Next up, you'll define the data model for your tasks and build a concurrency-safe store to hold them.
Define Your Task Model and In-Memory Store
Your Go environment is working and you've confirmed it by running a Hello World program.
Every REST API needs a data model. Before we can create, read, update, or delete tasks, we need to define what a "task" actually looks like in code. In this step, you'll define a Task struct and build a concurrency-safe in-memory store to hold your tasks.
In this step, get ready to:
- Create a Task struct with JSON struct tags.
- Build a TaskStore with full CRUD methods and concurrency-safe access.
- Run and verify that the code compiles correctly.
Create the Task struct
In Go, a struct is a collection of fields that groups related data together. Your Task struct defines what information each task holds.
- In VS Code, open main.go from the Explorer sidebar.
- Select all the existing code and replace it with the following:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
// Task represents a task in our task manager
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
What does this code do?
The import block brings in packages you'll use throughout this project. Some (like encoding/json and net/http) won't be used until the next step.
The Task struct has five exported fields (capitalized names make them accessible outside the package). The backtick annotations like `json:"id"` are struct tags that tell Go's JSON encoder what key names to use when converting to JSON.
What are struct tags?
Without struct tags, Go would serialize your field CreatedAt as "CreatedAt" in JSON. The tag `json:"created_at"` overrides this to use the snake_case convention that REST APIs typically follow.
You won't see a change yet. The file won't compile until you add a main() function at the end of this step.
Build the TaskStore with CRUD methods
Go's HTTP server handles each request in its own goroutine (a lightweight thread). If two requests try to modify the task map at the same time, you get a data race. A sync.Mutex acts as a lock that ensures only one goroutine accesses the map at a time.
- Add the following code below your Task struct in main.go:
// TaskStore provides concurrency-safe access to tasks
type TaskStore struct {
mu sync.Mutex
tasks map[string]Task
nextID int
}
// NewTaskStore creates a new TaskStore
func NewTaskStore() *TaskStore {
return &TaskStore{
tasks: make(map[string]Task),
}
}
// Create adds a new task to the store
func (s *TaskStore) Create(title, description string) Task {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
id := fmt.Sprintf("%d", s.nextID)
task := Task{
ID: id,
Title: title,
Description: description,
Status: "todo",
CreatedAt: time.Now(),
}
s.tasks[id] = task
return task
}
What does this code do?
- TaskStore holds a map[string]Task (Go's built-in hash map) to store tasks by their ID, plus a sync.Mutex for safe concurrent access.
- NewTaskStore() is a constructor function that initializes the map with make(). In Go, maps must be initialized before use.
- The Create method uses s.mu.Lock() and defer s.mu.Unlock() to lock access while modifying the map. The defer keyword ensures the unlock always runs, even if something goes wrong.
- The (s *TaskStore) before the function name is a method receiver. It attaches the function to the TaskStore type, like a method on a class in other languages.
- Now add the remaining read, update, and delete methods below the Create method:
// GetAll returns all tasks
func (s *TaskStore) GetAll() []Task {
s.mu.Lock()
defer s.mu.Unlock()
tasks := make([]Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return tasks
}
// GetByID returns a task by its ID
func (s *TaskStore) GetByID(id string) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
return task, ok
}
// Update modifies an existing task
func (s *TaskStore) Update(id, title, description, status string) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
if !ok {
return Task{}, false
}
if title != "" {
task.Title = title
}
if description != "" {
task.Description = description
}
if status != "" {
task.Status = status
}
s.tasks[id] = task
return task, true
}
// Delete removes a task by its ID
func (s *TaskStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.tasks[id]
if !ok {
return false
}
delete(s.tasks, id)
return true
}
What do these methods do?
- GetAll() creates a slice (Go's dynamic array), iterates over the map, and appends each task. The _ in the for loop discards the map key since you only need the value.
- GetByID() returns two values: the task and a boolean indicating whether it was found. Multiple return values are a core Go pattern for error handling.
- Update() only modifies fields that aren't empty strings, so you can update just the title without touching the description.
- Delete() uses Go's built-in delete() function to remove an entry from the map.
You won't see a change yet. The next chunk adds the main() function so you can compile and run.
Run your program to verify it compiles
Go requires every imported package to be used. Since you won't use encoding/json, log, and net/http until the next step, you'll add blank identifier references to keep the compiler happy.
- Replace your existing func main() block (the Hello World code) with this test code at the bottom of main.go:
func main() {
store := NewTaskStore()
task := store.Create("Test Task", "A quick test")
fmt.Printf("%+v\n", task)
_ = json.NewEncoder
_ = log.Fatal
_ = http.ListenAndServe
}
What does this code do?
- NewTaskStore() creates a fresh store, then Create() adds a task and returns it.
- fmt.Printf("%+v\n", task) prints the task with field names visible, so you can confirm the struct populated correctly.
- The _ = json.NewEncoder lines are blank identifiers. They reference the unused imports so the compiler doesn't complain. You'll remove these in the next step when you actually use those packages.
- Save main.go.
- Run your program by entering this command in the VS Code terminal:
go run .
- You should see output like this (your timestamp will differ):
{ID:1 Title:Test Task Description:A quick test Status:todo CreatedAt:2026-05-25 10:30:00.123456 +0000 UTC}
The printed struct confirms your store is creating tasks with auto-incrementing IDs and a default status of todo.
Seeing a compile error?
- If you see imported and not used, make sure you included the three blank identifier lines (_ = json.NewEncoder, _ = log.Fatal, _ = http.ListenAndServe) inside your main() function.
- If you see undefined: NewTaskStore, check that the NewTaskStore() function is defined above main() and starts with a capital N.
- If VS Code auto-removed some imports when you saved, re-add them manually. The Go extension sometimes removes imports it thinks are unused.
Help me debug my Go compile error.
VS Code may auto-remove imports
The Go extension's formatter removes unused imports on save. If it strips encoding/json, log, or net/http from your import block, add them back. The blank identifier lines in main() should prevent this, but re-add manually if needed.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go and that go run . prints your task struct.
ⓧ I'd like to double check the full code
Here is the complete main.go file at the end of this step:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
// Task represents a task in our task manager
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// TaskStore provides concurrency-safe access to tasks
type TaskStore struct {
mu sync.Mutex
tasks map[string]Task
nextID int
}
// NewTaskStore creates a new TaskStore
func NewTaskStore() *TaskStore {
return &TaskStore{
tasks: make(map[string]Task),
}
}
// Create adds a new task to the store
func (s *TaskStore) Create(title, description string) Task {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
id := fmt.Sprintf("%d", s.nextID)
task := Task{
ID: id,
Title: title,
Description: description,
Status: "todo",
CreatedAt: time.Now(),
}
s.tasks[id] = task
return task
}
// GetAll returns all tasks
func (s *TaskStore) GetAll() []Task {
s.mu.Lock()
defer s.mu.Unlock()
tasks := make([]Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return tasks
}
// GetByID returns a task by its ID
func (s *TaskStore) GetByID(id string) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
return task, ok
}
// Update modifies an existing task
func (s *TaskStore) Update(id, title, description, status string) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
if !ok {
return Task{}, false
}
if title != "" {
task.Title = title
}
if description != "" {
task.Description = description
}
if status != "" {
task.Status = status
}
s.tasks[id] = task
return task, true
}
// Delete removes a task by its ID
func (s *TaskStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.tasks[id]
if !ok {
return false
}
delete(s.tasks, id)
return true
}
func main() {
store := NewTaskStore()
task := store.Create("Test Task", "A quick test")
fmt.Printf("%+v\n", task)
_ = json.NewEncoder
_ = log.Fatal
_ = http.ListenAndServe
}
Your data model and storage layer are complete. Next up, you'll build the HTTP server that exposes these CRUD operations as REST endpoints.
Build Your HTTP Server and First Endpoints
With your data model and store in place, the next piece is making your tasks accessible over the network. An HTTP server turns your Go program into a service that other programs can talk to.
In this step, you'll set up Go's built-in HTTP router and register your first three endpoints. By the end, you'll be able to create and retrieve tasks by sending real requests to your running server.
In this step, get ready to:
- Set up an HTTP router and a helper function for JSON responses.
- Register endpoints for listing, creating, and fetching individual tasks.
- Start the server and test your API with real requests.
Set up an HTTP router and register a GET endpoint
Go's standard library includes a built-in HTTP router called ServeMux. It maps URL patterns to handler functions that process incoming requests.
Before registering any routes, you'll create a small helper function that handles writing JSON responses. Every endpoint needs to set the correct headers and encode data, so a reusable function keeps things clean.
- In main.go, find your main() function. Add this helper function directly above it:
// writeJSON writes a JSON response with the given status code
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
What does this code do?
This helper handles three things every JSON endpoint needs to do:
- w.Header().Set("Content-Type", "application/json") tells the client the response is JSON.
- w.WriteHeader(status) sends the HTTP status code (200, 201, 404, etc.).
- json.NewEncoder(w).Encode(data) converts your Go data into JSON and writes it to the response.
Now you'll replace the test code in main() with a real HTTP server. The router will map GET /tasks to a function that returns all tasks as JSON.
- Delete the entire body of your main() function (everything between the opening { and closing }).
- Replace it with this code:
func main() {
store := NewTaskStore()
mux := http.NewServeMux()
// GET /tasks - list all tasks
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
tasks := store.GetAll()
writeJSON(w, http.StatusOK, tasks)
})
}
What does this code do?
http.NewServeMux() creates a new router. You register routes on it using HandleFunc.
The pattern "GET /tasks" tells Go this handler only responds to GET requests at the /tasks path. This method-based routing is a feature introduced in Go 1.22.
The handler function receives an http.ResponseWriter (to write the response) and an *http.Request (containing the incoming request data).
- Save main.go.
Seeing a compile error?
If you see errors about unused imports (log or fmt), that is expected at this point. Go does not allow unused imports, but you will use both of these in the next few chunks. You can temporarily comment them out or continue to the next section where they get used.
help me fix unused import errors in my Go file
Add handlers for creating and retrieving individual tasks
Your API needs more than just listing tasks. The POST handler lets clients create new tasks by sending JSON in the request body. The GET-by-ID handler returns a single task using a path parameter.
- In main.go, find the closing }) of your GET /tasks handler inside main(). Add the following POST handler below it:
// POST /tasks - create a new task
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if input.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
task := store.Create(input.Title, input.Description)
writeJSON(w, http.StatusCreated, task)
})
What does this code do?
json.NewDecoder(r.Body).Decode(&input) reads the JSON from the request body and fills in the input struct. If the JSON is malformed, it returns a 400 Bad Request error.
The input struct is defined inline (anonymous struct). It only accepts title and description from the client. The server controls id, status, and created_at itself.
http.StatusCreated sends a 201 status code, which is the standard response for successfully creating a resource.
- Below the POST handler you just added, add the GET-by-ID handler:
// GET /tasks/{id} - get a task by ID
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
task, ok := store.GetByID(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
writeJSON(w, http.StatusOK, task)
})
What does this code do?
The pattern "GET /tasks/{id}" uses a path wildcard. When a request comes in to /tasks/3, Go captures 3 as the id value.
r.PathValue("id") extracts that captured value from the request. If the task does not exist in the store, the handler returns a 404 Not Found response.
- Save main.go.
Seeing a red underline on PathValue?
r.PathValue requires Go 1.22 or higher. If your editor flags it, confirm you installed Go 1.26.3 by running go version in your terminal.
help me fix PathValue not recognized in my Go project
Start the server and test your endpoints
Your routes are registered, but the server is not actually listening for connections yet. You need to tell Go to start accepting requests on a port.
- At the bottom of your main() function (after the last mux.HandleFunc block), add these two lines:
fmt.Println("Server starting on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
What does this code do?
http.ListenAndServe(":8080", mux) starts the server on port 8080 and routes all incoming requests through your mux router.
log.Fatal wraps the call so that if the server fails to start (for example, if port 8080 is already in use), it prints the error and exits.
- Save main.go.
- Start your server by running this command in the VS Code terminal:
go run .
You should see Server starting on http://localhost:8080 printed in your terminal. The terminal will appear to hang. This is normal. The server is running and waiting for requests.
Now you'll test your endpoints. You need a second terminal because the server is occupying the first one.
- Open a new terminal in VS Code by clicking **Terminal** in the top menu bar.
- Click **New Terminal**.
- Create a task by running this command:
Invoke-RestMethod -Uri http://localhost:8080/tasks -Method Post -Body '{"title":"Buy groceries","description":"Milk, eggs, bread"}' -ContentType "application/json"
- You should see a response showing your new task with an id, title, status of todo, and a created_at timestamp.
- List all tasks by running:
Invoke-RestMethod -Uri http://localhost:8080/tasks
- Fetch your task by ID by running:
Invoke-RestMethod -Uri http://localhost:8080/tasks/1
You should see the same task returned individually.
- Stop the server by pressing Ctrl+C in the first terminal (the one running the server).
Getting a connection refused error?
Make sure the server is still running in your first terminal. If you accidentally closed it, run go run . again.
If you see an error about port 8080 already being in use, another process is occupying it. Close any other servers or apps using that port and try again.
help me debug why my Go server connection is being refused
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and your server ran without compile errors.
ⓧ I'd like to double check the full code
Here is the full main.go as it should look at the end of this step:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
// Task represents a task in our task manager
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// TaskStore provides concurrency-safe access to tasks
type TaskStore struct {
mu sync.Mutex
tasks map[string]Task
nextID int
}
// NewTaskStore creates a new TaskStore
func NewTaskStore() *TaskStore {
return &TaskStore{
tasks: make(map[string]Task),
}
}
// Create adds a new task to the store
func (s *TaskStore) Create(title, description string) Task {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
id := fmt.Sprintf("%d", s.nextID)
task := Task{
ID: id,
Title: title,
Description: description,
Status: "todo",
CreatedAt: time.Now(),
}
s.tasks[id] = task
return task
}
// GetAll returns all tasks
func (s *TaskStore) GetAll() []Task {
s.mu.Lock()
defer s.mu.Unlock()
tasks := make([]Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return tasks
}
// GetByID returns a task by its ID
func (s *TaskStore) GetByID(id string) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
return task, ok
}
// Update modifies an existing task
func (s *TaskStore) Update(id, title, description, status string) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
if !ok {
return Task{}, false
}
if title != "" {
task.Title = title
}
if description != "" {
task.Description = description
}
if status != "" {
task.Status = status
}
s.tasks[id] = task
return task, true
}
// Delete removes a task by its ID
func (s *TaskStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.tasks[id]
if !ok {
return false
}
delete(s.tasks, id)
return true
}
// writeJSON writes a JSON response with the given status code
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() {
store := NewTaskStore()
mux := http.NewServeMux()
// GET /tasks - list all tasks
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
tasks := store.GetAll()
writeJSON(w, http.StatusOK, tasks)
})
// POST /tasks - create a new task
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if input.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
task := store.Create(input.Title, input.Description)
writeJSON(w, http.StatusCreated, task)
})
// GET /tasks/{id} - get a task by ID
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
task, ok := store.GetByID(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
writeJSON(w, http.StatusOK, task)
})
fmt.Println("Server starting on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Your API is alive. You can create tasks, list them, and fetch them by ID. Next up, you'll complete the CRUD operations with update and delete endpoints, plus add a logging middleware to track every request.
Complete CRUD and Add Middleware
Your API can list, create, and fetch tasks. However, a real task manager also needs to update task status and delete completed tasks.
In this step, you will add the remaining two CRUD endpoints and build a logging middleware that tracks every request hitting your server. You will also upgrade the server configuration with proper timeouts.
In this step, get ready to:
- Add PUT and DELETE handlers to complete all CRUD operations.
- Create a logging middleware that wraps your router.
- Configure production-style server timeouts and test all five endpoints.
Add the PUT and DELETE handlers
Right now your API can create and read tasks but cannot modify or remove them. The PUT endpoint will let clients update a task's title, description, or status. The DELETE endpoint will remove a task entirely.
- If your server is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
- In main.go, find the closing }) of the GET /tasks/{id} handler. Add the following PUT handler below it:
// PUT /tasks/{id} - update a task
mux.HandleFunc("PUT /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var input struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
task, ok := store.Update(id, input.Title, input.Description, input.Status)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
writeJSON(w, http.StatusOK, task)
})
What does this code do?
This handler responds to PUT /tasks/{id} requests. It extracts the task ID from the URL path, decodes the JSON body to read updated field values, then calls store.Update() to apply the changes.
The input struct here includes a Status field that the POST handler did not have. This lets clients mark tasks as done or in-progress after creation. If the task ID does not exist, the handler returns a 404 status.
- Directly below the PUT handler you just added, add the DELETE handler:
// DELETE /tasks/{id} - delete a task
mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !store.Delete(id) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
w.WriteHeader(http.StatusNoContent)
})
What does this code do?
The DELETE handler calls store.Delete(id) and returns 204 No Content on success. A 204 means the request succeeded but there is no response body to send back. If the task does not exist, it returns 404.
Notice how concise this handler is compared to PUT. Delete operations rarely need to parse a request body.
- Save main.go.
Seeing a compile error?
Make sure your new handlers are placed inside the main() function, after the existing GET /tasks/{id} handler and before the fmt.Println line. Each handler call must end with }) to close the anonymous function.
help me fix a compile error in my Go REST API handlers
Create a logging middleware
In Go, middleware is a function that wraps an http.Handler and returns a new http.Handler. It intercepts every request before it reaches your route handlers, letting you add cross-cutting behavior like logging, authentication, or rate limiting.
- In main.go, find the writeJSON function. Add the following loggingMiddleware function directly above it:
// loggingMiddleware logs the method, path, and duration of each request
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
What does this code do?
- loggingMiddleware takes an http.Handler (your mux) and returns a new http.Handler that wraps it.
- start := time.Now() captures the timestamp before the request is processed.
- next.ServeHTTP(w, r) passes the request to the actual route handler.
- log.Printf prints the HTTP method, URL path, and how long the request took.
Why this pattern instead of per-handler logging?
Adding log.Printf inside each handler would mean repeating the same code five times. Middleware applies the behavior once and covers every route automatically. This is the same pattern used in production Go services for authentication, CORS, and metrics collection.
- Save main.go.
Seeing an unused import error?
If you see a message about log being imported but not used, it means the middleware function is not yet being called anywhere. You will wire it up in the next substep.
help me fix an unused import error in my Go code
Wire up the middleware and configure server timeouts
Your middleware function exists but is not connected to anything yet. You also need to replace the basic http.ListenAndServe call with a properly configured http.Server struct that includes read and write timeouts.
- In main.go, find these two lines at the bottom of main():
fmt.Println("Server starting on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
- Replace those two lines with the following code:
// Wrap mux with logging middleware
handler := loggingMiddleware(mux)
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
fmt.Println("Server starting on http://localhost:8080")
log.Fatal(server.ListenAndServe())
What does this code do?
- handler := loggingMiddleware(mux) wraps your router so every request passes through the logger first.
- &http.Server{...} creates a server struct with explicit timeouts. Without these, a slow client could hold a connection open indefinitely and starve your server of resources.
- ReadTimeout limits how long the server waits to read the full request. WriteTimeout limits how long the server takes to write the response.
- server.ListenAndServe() starts the configured server instead of the bare http.ListenAndServe function.
- Save main.go.
- Start your server by running:
go run .
You should see Server starting on http://localhost:8080 in the terminal.
- Open a second terminal in VS Code by clicking Terminal in the top menu, then New Terminal.
- Test the PUT endpoint by first creating a task, then updating it. Create a task by running:
Invoke-RestMethod -Uri http://localhost:8080/tasks -Method Post -Body '{"title":"Buy groceries","description":"Milk and eggs"}' -ContentType "application/json"
- Update the task status to done by running:
Invoke-RestMethod -Uri http://localhost:8080/tasks/1 -Method Put -Body '{"status":"done"}' -ContentType "application/json"
You should see the task returned with status : done in the response.
- Test the DELETE endpoint by running:
Invoke-RestMethod -Uri http://localhost:8080/tasks/1 -Method Delete
A successful delete returns no output because the server responds with 204 No Content.
- Switch back to your first terminal (the one running the server). You should see log lines for each request you made, showing the HTTP method, path, and response duration.
Not seeing any log output?
Make sure you replaced the old http.ListenAndServe(":8080", mux) line. The new code passes handler (the middleware-wrapped version) to the server struct. If you still pass mux directly, requests bypass the middleware entirely.
help me debug why my Go logging middleware is not printing output
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that all five endpoints are working.
ⓧ I'd like to double check the full code
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
// Task represents a task in our task manager
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// TaskStore provides concurrency-safe access to tasks
type TaskStore struct {
mu sync.Mutex
tasks map[string]Task
nextID int
}
// NewTaskStore creates a new TaskStore
func NewTaskStore() *TaskStore {
return &TaskStore{
tasks: make(map[string]Task),
}
}
// Create adds a new task to the store
func (s *TaskStore) Create(title, description string) Task {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
id := fmt.Sprintf("%d", s.nextID)
task := Task{
ID: id,
Title: title,
Description: description,
Status: "todo",
CreatedAt: time.Now(),
}
s.tasks[id] = task
return task
}
// GetAll returns all tasks
func (s *TaskStore) GetAll() []Task {
s.mu.Lock()
defer s.mu.Unlock()
tasks := make([]Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return tasks
}
// GetByID returns a task by its ID
func (s *TaskStore) GetByID(id string) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
return task, ok
}
// Update modifies an existing task
func (s *TaskStore) Update(id, title, description, status string) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
if !ok {
return Task{}, false
}
if title != "" {
task.Title = title
}
if description != "" {
task.Description = description
}
if status != "" {
task.Status = status
}
s.tasks[id] = task
return task, true
}
// Delete removes a task by its ID
func (s *TaskStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.tasks[id]
if !ok {
return false
}
delete(s.tasks, id)
return true
}
// loggingMiddleware logs the method, path, and duration of each request
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
// writeJSON writes a JSON response with the given status code
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() {
store := NewTaskStore()
mux := http.NewServeMux()
// GET /tasks - list all tasks
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
tasks := store.GetAll()
writeJSON(w, http.StatusOK, tasks)
})
// POST /tasks - create a new task
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if input.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
task := store.Create(input.Title, input.Description)
writeJSON(w, http.StatusCreated, task)
})
// GET /tasks/{id} - get a task by ID
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
task, ok := store.GetByID(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
writeJSON(w, http.StatusOK, task)
})
// PUT /tasks/{id} - update a task
mux.HandleFunc("PUT /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var input struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
task, ok := store.Update(id, input.Title, input.Description, input.Status)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
writeJSON(w, http.StatusOK, task)
})
// DELETE /tasks/{id} - delete a task
mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !store.Delete(id) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
w.WriteHeader(http.StatusNoContent)
})
// Wrap mux with logging middleware
handler := loggingMiddleware(mux)
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
fmt.Println("Server starting on http://localhost:8080")
log.Fatal(server.ListenAndServe())
}
Your API now has full CRUD capabilities and logs every request automatically. Next up, you will write automated tests to verify all your endpoints work correctly without manual testing.
Write Tests for Your API
Your API is fully functional with all five endpoints, middleware, and proper error handling. The next question is: how do you prove it works correctly every time you make a change?
Go has a built-in testing package that lets you write automated tests without installing anything extra. You'll use httptest to simulate HTTP requests against your handlers without starting a real server.
In this step, get ready to:
- Create a test file with a reusable test server helper.
- Write tests that verify your endpoints return correct status codes and response bodies.
- Run the full test suite and confirm everything passes.
Create your test file and setup helper
Go test files live alongside the code they test. The convention is to name them with a _test.go suffix.
- Stop your running server by pressing Ctrl+C in the terminal.
- Create a new file called main_test.go in the same folder as main.go.
- Add the package declaration, imports, and test setup helper by pasting this code:
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func setupTestServer() (*http.ServeMux, *TaskStore) {
store := NewTaskStore()
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
tasks := store.GetAll()
writeJSON(w, http.StatusOK, tasks)
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if input.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
task := store.Create(input.Title, input.Description)
writeJSON(w, http.StatusCreated, task)
})
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
task, ok := store.GetByID(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
writeJSON(w, http.StatusOK, task)
})
mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !store.Delete(id) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
w.WriteHeader(http.StatusNoContent)
})
return mux, store
}
What does this code do?
The setupTestServer() function creates a fresh TaskStore and ServeMux for each test. This means every test starts with a clean slate and no leftover data from other tests.
The httptest package provides tools to test HTTP handlers without starting a real server or binding a port. You send fake requests directly to the handler and inspect the recorded response.
Now add your first test function. This test verifies that POST /tasks creates a task and returns a 201 status code.
- Add the TestCreateTask function below setupTestServer():
func TestCreateTask(t *testing.T) {
mux, _ := setupTestServer()
body := bytes.NewBufferString(`{"title":"Buy groceries","description":"Milk, eggs, bread"}`)
req := httptest.NewRequest("POST", "/tasks", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", rec.Code)
}
var task Task
json.NewDecoder(rec.Body).Decode(&task)
if task.Title != "Buy groceries" {
t.Errorf("expected title 'Buy groceries', got '%s'", task.Title)
}
if task.Status != "todo" {
t.Errorf("expected status 'todo', got '%s'", task.Status)
}
}
What does this test do?
Each Go test function must start with Test and accept *testing.T as its only parameter. The testing package uses this naming convention to find and run your tests automatically.
- httptest.NewRequest() creates a fake HTTP request with the method, path, and body you specify.
- httptest.NewRecorder() captures the response your handler writes, so you can inspect the status code and body.
- mux.ServeHTTP(rec, req) sends the fake request through your router, just like a real HTTP call would.
- t.Errorf() marks the test as failed and prints a descriptive message.
- Save main_test.go.
Seeing red underlines in VS Code?
The Go extension may show errors until you save. This is because Go needs both files in the package to resolve types like Task and functions like writeJSON. Save the file and the errors should clear within a few seconds.
Help me fix red underlines in my Go test file
Write tests for GET endpoints
Your create test verifies writing data. Now you need tests that verify reading data back.
- Add the TestGetAllTasks function below TestCreateTask:
func TestGetAllTasks(t *testing.T) {
mux, store := setupTestServer()
store.Create("Task 1", "Description 1")
store.Create("Task 2", "Description 2")
req := httptest.NewRequest("GET", "/tasks", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rec.Code)
}
var tasks []Task
json.NewDecoder(rec.Body).Decode(&tasks)
if len(tasks) != 2 {
t.Errorf("expected 2 tasks, got %d", len(tasks))
}
}
What does this test do?
This test uses the store returned by setupTestServer() to directly insert two tasks before making the GET request. This lets you control exactly what the handler should return, making the test predictable.
- Add the TestGetTaskByID function below TestGetAllTasks:
func TestGetTaskByID(t *testing.T) {
mux, store := setupTestServer()
created := store.Create("Test Task", "Test Description")
req := httptest.NewRequest("GET", "/tasks/"+created.ID, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rec.Code)
}
var task Task
json.NewDecoder(rec.Body).Decode(&task)
if task.Title != "Test Task" {
t.Errorf("expected title 'Test Task', got '%s'", task.Title)
}
}
What does this test do?
This test creates a task, then builds a GET request to /tasks/ plus the task's ID. It verifies the handler returns a 200 status and the correct task title. Notice how created.ID gives you the ID assigned by the store, so the URL is always accurate.
- Save main_test.go.
- Run your tests by entering this command in the terminal:
go test -v
You should see three tests listed, each with a PASS result.
What does the -v flag do?
The -v flag stands for verbose. Without it, Go only prints output for failing tests. With it, you see every test function name and its result, which is helpful while developing.
Tests failing or not found?
Make sure main_test.go is in the same folder as main.go. Both files must declare package main at the top. If you see a compile error, check that every opening brace has a matching closing brace.
Help me debug my Go test failures
Write a test for DELETE and run the full suite
The delete test is the most thorough. It verifies both that the delete succeeds AND that the task is actually gone afterwards.
- Add the TestDeleteTask function below TestGetTaskByID:
func TestDeleteTask(t *testing.T) {
mux, store := setupTestServer()
created := store.Create("To Delete", "Will be removed")
req := httptest.NewRequest("DELETE", "/tasks/"+created.ID, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d", rec.Code)
}
// Verify task is gone
req = httptest.NewRequest("GET", "/tasks/"+created.ID, nil)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("expected status 404 after delete, got %d", rec.Code)
}
}
What does this test do?
This test uses a two-step verification pattern. First, it sends a DELETE request and checks for a 204 (No Content) response. Then it sends a GET request for the same ID and confirms the server returns 404 (Not Found).
This is stronger than just checking the delete response. It proves the task was actually removed from the store, not just that the handler said it was.
- Save main_test.go.
- Run the full test suite by entering this command:
go test -v
You should see all four tests listed with PASS next to each, and an overall ok result at the bottom.
Seeing a FAIL result?
Read the error message carefully. It shows you what the test expected versus what it got. Common causes: a missing handler in setupTestServer(), a typo in the route pattern, or a mismatched status code.
Help me fix my failing Go tests
✔️ Awesome, I've got everything!
Great. Double check you've saved main_test.go and all four tests pass.
ⓧ I'd like to double check the full code
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func setupTestServer() (*http.ServeMux, *TaskStore) {
store := NewTaskStore()
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
tasks := store.GetAll()
writeJSON(w, http.StatusOK, tasks)
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if input.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
task := store.Create(input.Title, input.Description)
writeJSON(w, http.StatusCreated, task)
})
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
task, ok := store.GetByID(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
writeJSON(w, http.StatusOK, task)
})
mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !store.Delete(id) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
w.WriteHeader(http.StatusNoContent)
})
return mux, store
}
func TestCreateTask(t *testing.T) {
mux, _ := setupTestServer()
body := bytes.NewBufferString(`{"title":"Buy groceries","description":"Milk, eggs, bread"}`)
req := httptest.NewRequest("POST", "/tasks", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", rec.Code)
}
var task Task
json.NewDecoder(rec.Body).Decode(&task)
if task.Title != "Buy groceries" {
t.Errorf("expected title 'Buy groceries', got '%s'", task.Title)
}
if task.Status != "todo" {
t.Errorf("expected status 'todo', got '%s'", task.Status)
}
}
func TestGetAllTasks(t *testing.T) {
mux, store := setupTestServer()
store.Create("Task 1", "Description 1")
store.Create("Task 2", "Description 2")
req := httptest.NewRequest("GET", "/tasks", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rec.Code)
}
var tasks []Task
json.NewDecoder(rec.Body).Decode(&tasks)
if len(tasks) != 2 {
t.Errorf("expected 2 tasks, got %d", len(tasks))
}
}
func TestGetTaskByID(t *testing.T) {
mux, store := setupTestServer()
created := store.Create("Test Task", "Test Description")
req := httptest.NewRequest("GET", "/tasks/"+created.ID, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rec.Code)
}
var task Task
json.NewDecoder(rec.Body).Decode(&task)
if task.Title != "Test Task" {
t.Errorf("expected title 'Test Task', got '%s'", task.Title)
}
}
func TestDeleteTask(t *testing.T) {
mux, store := setupTestServer()
created := store.Create("To Delete", "Will be removed")
req := httptest.NewRequest("DELETE", "/tasks/"+created.ID, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d", rec.Code)
}
// Verify task is gone
req = httptest.NewRequest("GET", "/tasks/"+created.ID, nil)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("expected status 404 after delete, got %d", rec.Code)
}
}
Secret mission
Add Filtering and a Health Check
Your API works, but production APIs need more. Can you add task filtering by status and a health check endpoint that monitoring tools can ping?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The taskmanager project folder on your Desktop (main.go, main_test.go, go.mod).
- Go 1.26.3 installed via the Windows MSI installer.
- VS Code with the Go extension (golang.go).
Keep everything running
No action needed. Choose this if you're still actively building or want to revisit the project later.
- Your taskmanager folder stays on your Desktop.
- Go and VS Code remain installed and ready for your next project.
Pause - I'll come back to this later
Shut down any running processes to free up memory. Your files stay on disk for when you return.
- If your server is still running in the terminal, press Ctrl+C to stop it.
- Close VS Code.
Delete - I don't want to use this again
Remove all project resources and optionally uninstall the tools.
- If your server is still running, press Ctrl+C to stop it.
- Delete the project folder by running this in PowerShell:
Remove-Item -Recurse -Force ~\Desktop\taskmanager
- To uninstall Go, open Settings, then select Apps, then Installed apps. Find Go Programming Language in the list and click Uninstall.
- To uninstall VS Code, follow the same path in Settings > Apps > Installed apps. Find Microsoft Visual Studio Code and click Uninstall.
Nice Work!
Nice Work!
You just built a production-style REST API in Go from scratch. The entire thing runs on Go's standard library with zero external dependencies.
Here's what you learned:
- Built a concurrency-safe in-memory data store using sync.Mutex to protect shared state across goroutines.
- Created a full CRUD REST API using only Go's standard library (net/http, encoding/json) with proper HTTP status codes and a logging middleware.
- Tested your API with Go's built-in testing and httptest packages to verify endpoints without starting a real server.
- Added query parameter filtering and a health check endpoint to make your API production-ready.
Ready to quiz yourself?