Overview
A CLI task manager is the project where Go's explicit, no-surprises philosophy first becomes obvious: there is no exception system to silently swallow a missing file, no implicit constructor doing hidden work behind your back. A `Task` is a plain `struct` — a handful of named fields with no behavior baked into the type itself — and every operation that can go wrong, from opening a file to parsing JSON, returns an `error` value that the calling code must check explicitly with `if err != nil`. Nothing happens by accident in Go, and this project is small enough to feel that discipline in every function you write.
By the end of this tutorial you will have a command-line application that stores its to-do list as a JSON file on disk, using Go's `encoding/json` package to marshal a slice of `Task` structs into readable JSON and unmarshal it straight back into memory on the next run. You will also see the idiomatic Go pattern of returning `(value, error)` pairs from nearly every function, and why that convention exists in a language with no `try`/`catch` at all.
- A `Task` struct with `ID`, `Title`, and `Done` fields, tagged for JSON serialization.
- A `loadTasks()` function that reads `tasks.json` and unmarshals it into a `[]Task`.
- A `saveTasks()` function that marshals the current `[]Task` back to `tasks.json`.
- An `addTask()` function that appends a new task and assigns it the next unused id.
- A `completeTask()` function that marks a task done by id, and a `deleteTask()` that removes one.
- A menu-driven command loop tying every operation together into one running program.
Prerequisites
- Structs — defining a `struct` with named, typed fields and struct tags.
- Slices — `append()`, indexing, and looping with `for range`.
- Error handling — functions returning `(T, error)` and the `if err != nil` check.
- The `encoding/json` package — `json.Marshal` and `json.Unmarshal`.
- Basic file I/O — `os.ReadFile` and `os.WriteFile`.
Project Structure
The whole program lives in a single file, `main.go`, in package `main`. A `Task` struct models one to-do item, and every function that touches the task list — `loadTasks`, `saveTasks`, `addTask`, `completeTask`, `deleteTask` — operates on a `[]Task` slice passed in and, where the list changes shape, returned back out. Go slices are not pointers to a fixed collection the way a Java `ArrayList` reference is; `append()` can return a slice backed by an entirely new underlying array, so every function that adds or removes a task returns the updated `[]Task` rather than mutating one in place.
`main()` owns the one long-lived copy of the task slice and the menu loop around it. Every helper function is stateless — it takes the current slice as an argument and returns the next one — which keeps the file-load/file-save boundary in exactly two places instead of scattered across every command.
Step 1: Define the Task Struct
Each field name starts with an uppercase letter, which in Go is not a style preference — it is what makes a field exported (visible outside the package, and to `encoding/json`). The backtick-delimited string after each field is a struct tag; `json:"id"` tells `encoding/json` to use the lowercase key `"id"` in the JSON output instead of the Go field name `ID`, which is the idiomatic way to keep Go's exported-field capitalization convention separate from conventional lowercase JSON keys.
package main
// Task represents one to-do item. Fields must start with an uppercase// letter to be "exported" — visible to other packages and, critically,// to encoding/json, which only ever sees exported fields.type Task struct { ID int `json:"id"` // Unique, auto-incrementing id assigned by addTask() Title string `json:"title"` // What the task is Done bool `json:"done"` // Whether the task has been completed}Step 2: Load and Save Tasks as JSON
`loadTasks()` reads the raw bytes of `tasks.json` with `os.ReadFile`, then hands them to `json.Unmarshal` to decode into a `[]Task`. The very first run of the program has no file yet, so `loadTasks()` treats `os.IsNotExist(err)` as "start with an empty list" rather than a fatal error — every other read error is still returned up to the caller, since silently ignoring a corrupt or unreadable file would be worse than crashing loudly.
import ( "encoding/json" // Marshal/Unmarshal between []Task and JSON bytes "os" // ReadFile/WriteFile for the tasks.json file)
// loadTasks reads tasks.json and decodes it into a []Task. A missing file// is treated as "no tasks yet" rather than an error, since that is exactly// the state of a brand-new install that has never saved anything.func loadTasks(filename string) ([]Task, error) { data, err := os.ReadFile(filename) if err != nil { if os.IsNotExist(err) { // First run: no file yet is expected, not an error return []Task{}, nil } return nil, err // Any other read failure (permissions, etc.) is a real error to report }
var tasks []Task if err := json.Unmarshal(data, &tasks); err != nil { // &tasks: Unmarshal writes into the slice via a pointer return nil, err } return tasks, nil}
// saveTasks encodes the current task list as indented JSON and writes it// to filename, overwriting whatever was there before.func saveTasks(filename string, tasks []Task) error { data, err := json.MarshalIndent(tasks, "", " ") // Indented output so tasks.json stays human-readable if err != nil { return err } return os.WriteFile(filename, data, 0644) // 0644: owner read/write, everyone else read-only}Click Run to see what this code prints.
Step 3: Add and List Tasks
`addTask()` computes the next id by scanning the existing tasks for the current maximum rather than just using `len(tasks)+1` — that way, ids stay unique even after a task with a high id has been deleted, instead of a new task accidentally reusing a freed slot's id. It returns the updated `[]Task`, since `append()` may allocate a new backing array under the hood; the caller must always use the returned slice, never assume the original variable was mutated in place.
import "fmt"
// addTask appends a new, not-done task and returns the updated slice.// The caller must reassign its variable to this return value, since// append() can return a slice backed by a different underlying array.func addTask(tasks []Task, title string) []Task { nextID := 1 for _, t := range tasks { // Find the highest existing id so new ids never collide with a deleted one's old id if t.ID >= nextID { nextID = t.ID + 1 } } newTask := Task{ID: nextID, Title: title, Done: false} return append(tasks, newTask) // append() may grow the slice into a new array; always use its return value}
// listTasks prints every task with a checkbox showing its done state.func listTasks(tasks []Task) { if len(tasks) == 0 { fmt.Println("No tasks yet.") return } for _, t := range tasks { // Range over a slice by value; t is a copy, safe to read but never mutates tasks mark := " " // Space inside the checkbox means "not done" if t.Done { mark = "x" } fmt.Printf("[%s] %d. %s\n", mark, t.ID, t.Title) }}Step 4: Complete and Delete Tasks
`completeTask()` loops with the two-value form of `range`, using the index `i` to reach into the slice and flip `Done` on the actual element — ranging with just `t` would only modify a copy, since Go slice elements are copied into the loop variable by value. `deleteTask()` builds a fresh slice of everything except the matching id, which is the idiomatic Go way to "remove" an element without the pointer-juggling an in-place removal would require.
// completeTask marks the task with the given id as done. It returns false// if no task with that id exists, so the caller can report "not found".func completeTask(tasks []Task, id int) bool { for i := range tasks { // Index form of range: needed to mutate the real element, not a copy of it if tasks[i].ID == id { tasks[i].Done = true return true } } return false // No match; caller decides how to report this}
// deleteTask removes the task with the given id and returns the updated// slice. Building a new slice of "everything that doesn't match" is the// idiomatic Go way to delete from a slice.func deleteTask(tasks []Task, id int) ([]Task, bool) { result := make([]Task, 0, len(tasks)) // Pre-size capacity; at most len(tasks) items will ever be appended removed := false for _, t := range tasks { if t.ID == id { removed = true continue // Skip this one; everything else gets appended below } result = append(result, t) } return result, removed}Step 5: Build the Menu Loop
`main()` loads the saved task list once at startup, then loops on a numbered menu using `bufio.NewReader(os.Stdin)` to read whole lines — `fmt.Scanln` alone would stop at the first space, which breaks multi-word task titles. Every command that changes the list ends by calling `saveTasks()` immediately, so the JSON file on disk is never more than one action behind what is in memory.
import ( "bufio" // Reads whole lines of input, including spaces, unlike fmt.Scanln "fmt" "os" "strconv" // Atoi: parse a typed-in id string into an int "strings" // TrimSpace: strip the trailing newline bufio.Reader leaves on each line)
const dataFile = "tasks.json"
func main() { tasks, err := loadTasks(dataFile) // One shared []Task, updated and saved after every change if err != nil { fmt.Println("Error loading tasks:", err) return }
reader := bufio.NewReader(os.Stdin)
for { fmt.Println("\n===== CLI TASK MANAGER =====") fmt.Println("1. Add Task") fmt.Println("2. List Tasks") fmt.Println("3. Complete Task") fmt.Println("4. Delete Task") fmt.Println("5. Exit") fmt.Print("Enter your choice: ")
choiceStr, _ := reader.ReadString('\n') // ReadString keeps reading until it sees the delimiter byte choice := strings.TrimSpace(choiceStr) // Strip the trailing "\n" (and any "\r" on Windows)
switch choice { case "1": fmt.Print("Enter task title: ") titleStr, _ := reader.ReadString('\n') title := strings.TrimSpace(titleStr) tasks = addTask(tasks, title) // Reassign: addTask's returned slice is the source of truth from here on saveTasks(dataFile, tasks) fmt.Println("Task added.") case "2": listTasks(tasks) case "3": fmt.Print("Enter task ID to complete: ") idStr, _ := reader.ReadString('\n') id, err := strconv.Atoi(strings.TrimSpace(idStr)) // Atoi returns an error for non-numeric input if err != nil { fmt.Println("Please enter a valid number.") continue } if completeTask(tasks, id) { saveTasks(dataFile, tasks) fmt.Println("Task marked complete.") } else { fmt.Println("No task with that ID.") } case "4": fmt.Print("Enter task ID to delete: ") idStr, _ := reader.ReadString('\n') id, err := strconv.Atoi(strings.TrimSpace(idStr)) if err != nil { fmt.Println("Please enter a valid number.") continue } updated, removed := deleteTask(tasks, id) if removed { tasks = updated // Only reassign on success; a no-op delete shouldn't touch the in-memory list saveTasks(dataFile, tasks) fmt.Println("Task deleted.") } else { fmt.Println("No task with that ID.") } case "5": fmt.Println("Goodbye!") return // Exits main(), which ends the program default: fmt.Println("Invalid choice, try again.") } }}Complete Code
Here is the full program assembled in one file, ready to save as `main.go` and run with `go run main.go`.
package main
import ( "bufio" "encoding/json" "fmt" "os" "strconv" "strings")
const dataFile = "tasks.json"
type Task struct { ID int `json:"id"` Title string `json:"title"` Done bool `json:"done"`}
func loadTasks(filename string) ([]Task, error) { data, err := os.ReadFile(filename) if err != nil { if os.IsNotExist(err) { return []Task{}, nil } return nil, err }
var tasks []Task if err := json.Unmarshal(data, &tasks); err != nil { return nil, err } return tasks, nil}
func saveTasks(filename string, tasks []Task) error { data, err := json.MarshalIndent(tasks, "", " ") if err != nil { return err } return os.WriteFile(filename, data, 0644)}
func addTask(tasks []Task, title string) []Task { nextID := 1 for _, t := range tasks { if t.ID >= nextID { nextID = t.ID + 1 } } newTask := Task{ID: nextID, Title: title, Done: false} return append(tasks, newTask)}
func listTasks(tasks []Task) { if len(tasks) == 0 { fmt.Println("No tasks yet.") return } for _, t := range tasks { mark := " " if t.Done { mark = "x" } fmt.Printf("[%s] %d. %s\n", mark, t.ID, t.Title) }}
func completeTask(tasks []Task, id int) bool { for i := range tasks { if tasks[i].ID == id { tasks[i].Done = true return true } } return false}
func deleteTask(tasks []Task, id int) ([]Task, bool) { result := make([]Task, 0, len(tasks)) removed := false for _, t := range tasks { if t.ID == id { removed = true continue } result = append(result, t) } return result, removed}
func main() { tasks, err := loadTasks(dataFile) if err != nil { fmt.Println("Error loading tasks:", err) return }
reader := bufio.NewReader(os.Stdin)
for { fmt.Println("\n===== CLI TASK MANAGER =====") fmt.Println("1. Add Task") fmt.Println("2. List Tasks") fmt.Println("3. Complete Task") fmt.Println("4. Delete Task") fmt.Println("5. Exit") fmt.Print("Enter your choice: ")
choiceStr, _ := reader.ReadString('\n') choice := strings.TrimSpace(choiceStr)
switch choice { case "1": fmt.Print("Enter task title: ") titleStr, _ := reader.ReadString('\n') title := strings.TrimSpace(titleStr) tasks = addTask(tasks, title) saveTasks(dataFile, tasks) fmt.Println("Task added.") case "2": listTasks(tasks) case "3": fmt.Print("Enter task ID to complete: ") idStr, _ := reader.ReadString('\n') id, err := strconv.Atoi(strings.TrimSpace(idStr)) if err != nil { fmt.Println("Please enter a valid number.") continue } if completeTask(tasks, id) { saveTasks(dataFile, tasks) fmt.Println("Task marked complete.") } else { fmt.Println("No task with that ID.") } case "4": fmt.Print("Enter task ID to delete: ") idStr, _ := reader.ReadString('\n') id, err := strconv.Atoi(strings.TrimSpace(idStr)) if err != nil { fmt.Println("Please enter a valid number.") continue } updated, removed := deleteTask(tasks, id) if removed { tasks = updated saveTasks(dataFile, tasks) fmt.Println("Task deleted.") } else { fmt.Println("No task with that ID.") } case "5": fmt.Println("Goodbye!") return default: fmt.Println("Invalid choice, try again.") } }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a `Priority` field (`"low"`/`"medium"`/`"high"`) to `Task` and a command to sort tasks by it before listing.
- Support command-line flags with the `flag` package so tasks can be added or listed non-interactively, e.g. `go run main.go -add "Buy milk"`.
- Add a `DueDate` field using `time.Time` and highlight overdue tasks in the listing.
- Add a `findByTitle(tasks []Task, query string) []Task` helper using `strings.Contains` for case-insensitive search.
- Wrap file access in a `sync.Mutex` and expose the same operations over a small `net/http` server, reusing the JSON persistence layer from Step 2 as-is.
Summary
You built a working task manager where a plain `Task` struct is serialized to and from JSON with `encoding/json`, and every function that can fail — reading a file, parsing JSON, converting a string to an int — returns an explicit `error` that the caller is expected to check. That `(value, error)` return pattern, and the discipline of reassigning a slice after every `append()`, are two of the most fundamental habits in idiomatic Go, and you will reach for both in nearly every program you write from here on.