LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.