LearnAI ToolsCareerPractice BuildsPlayContact
Go (Golang)Beginner~1.5 hours

CLI Task Manager

Build a command-line to-do app that reads and writes tasks to a JSON file.

StructsFile I/OJSON

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.

What You'll Build
  • 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
}
Example Usage

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

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.