How to Build a TODO API in Go (Full CRUD)

Web
You can build a full CRUD TODO API in Go using the standard library's `net/http` package by defining a struct for your data, creating a slice to act as an in-memory store, and registering four handler functions for Create, Read, Update, and Delete operations.

You can build a full CRUD TODO API in Go using the standard library's net/http package by defining a struct for your data, creating a slice to act as an in-memory store, and registering four handler functions for Create, Read, Update, and Delete operations. This approach requires no external dependencies and provides a clear foundation for adding a real database later.

Here is a complete, runnable example using a single file. It defines a Todo struct, manages state in a global slice, and uses JSON encoding for the request/response body.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"sync"
)

type Todo struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

var (
	todos   = []Todo{}
	nextID  = 1
	mu      sync.Mutex // Protects concurrent access to the slice
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/todos", handleTodos)
	mux.HandleFunc("/todos/", handleSingleTodo)

	fmt.Println("Server running on :8080")
	http.ListenAndServe(":8080", mux)
}

func handleTodos(w http.ResponseWriter, r *http.Request) {
	mu.Lock()
	defer mu.Unlock()

	switch r.Method {
	case http.MethodGet:
		json.NewEncoder(w).Encode(todos)
	case http.MethodPost:
		var todo Todo
		if err := json.NewDecoder(r.Body).Decode(&todo); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		todo.ID = nextID
		nextID++
		todos = append(todos, todo)
		w.WriteHeader(http.StatusCreated)
		json.NewEncoder(w).Encode(todo)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

func handleSingleTodo(w http.ResponseWriter, r *http.Request) {
	// Extract ID from URL path (e.g., /todos/1)
	idStr := r.URL.Path[len("/todos/"):]
	var id int
	fmt.Sscanf(idStr, "%d", &id)

	mu.Lock()
	defer mu.Unlock()

	for i, t := range todos {
		if t.ID == id {
			switch r.Method {
			case http.MethodGet:
				json.NewEncoder(w).Encode(t)
			case http.MethodPut:
				var updated Todo
				if err := json.NewDecoder(r.Body).Decode(&updated); err != nil {
					http.Error(w, err.Error(), http.StatusBadRequest)
					return
				}
				updated.ID = id // Ensure ID doesn't change
				todos[i] = updated
				json.NewEncoder(w).Encode(updated)
			case http.MethodDelete:
				todos = append(todos[:i], todos[i+1:]...)
				w.WriteHeader(http.StatusNoContent)
			default:
				http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
			}
			return
		}
	}
	http.Error(w, "Todo not found", http.StatusNotFound)
}

To test the API, you can use curl commands directly in your terminal. First, create a new task:

curl -X POST http://localhost:8080/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries", "done": false}'

Next, retrieve all tasks, update the specific one by ID, and finally delete it:

# Get all
curl http://localhost:8080/todos

# Update ID 1
curl -X PUT http://localhost:8080/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries", "done": true}'

# Delete ID 1
curl -X DELETE http://localhost:8080/todos/1

This implementation handles concurrency safely using a sync.Mutex to prevent race conditions when multiple requests hit the server simultaneously. While this uses an in-memory slice, you can easily replace the todos slice and the locking logic with a database connection pool (like sqlx or gorm) for production use.