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
Taskstruct withID,Title, andDonefields, tagged for JSON serialization. - A
loadTasks()function that readstasks.jsonand unmarshals it into a[]Task. - A
saveTasks()function that marshals the current[]Taskback totasks.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 adeleteTask()that removes one. - A menu-driven command loop tying every operation together into one running program.
Prerequisites
- Structs — defining a
structwith named, typed fields and struct tags. - Slices —
append(), indexing, and looping withfor range. - Error handling — functions returning
(T, error)and theif err != nilcheck. - The
encoding/jsonpackage —json.Marshalandjson.Unmarshal. - Basic file I/O —
os.ReadFileandos.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
Priorityfield ("low"/"medium"/"high") toTaskand a command to sort tasks by it before listing. - Support command-line flags with the
flagpackage so tasks can be added or listed non-interactively, e.g.go run main.go -add "Buy milk". - Add a
DueDatefield usingtime.Timeand highlight overdue tasks in the listing. - Add a
findByTitle(tasks []Task, query string) []Taskhelper usingstrings.Containsfor case-insensitive search. - Wrap file access in a
sync.Mutexand expose the same operations over a smallnet/httpserver, 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.