1 The Problem
We want a to-do list you can actually use: add tasks, see them numbered, remove the ones you finish, and — crucially — have them saved to a file so they survive after you close the program. It teaches lists, a menu loop, and saving data to disk.
2 How to Think About It
Think about the loop the program lives in, before any code:
3 The Build — explained part by part
Here is the complete to-do list. Each part is explained below.
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
)
const file = "tasks.json"
// loadTasks reads saved tasks from disk, or returns an empty list if none exist yet.
func loadTasks() []string {
data, err := os.ReadFile(file)
if err != nil {
return []string{}
}
var tasks []string
if err := json.Unmarshal(data, &tasks); err != nil {
return []string{}
}
return tasks
}
// saveTasks writes the tasks to disk so they survive after the program closes.
func saveTasks(tasks []string) {
data, _ := json.Marshal(tasks)
os.WriteFile(file, data, 0644)
}
// addTask appends a task — pulled out on its own so tests can check it directly.
func addTask(tasks []string, task string) []string {
return append(tasks, task)
}
// removeTask removes task number `number` (counting from 1). An out-of-range
// number is ignored and the list comes back unchanged.
func removeTask(tasks []string, number int) []string {
if number >= 1 && number <= len(tasks) {
return append(tasks[:number-1], tasks[number:]...)
}
return tasks
}
func main() {
tasks := loadTasks()
reader := bufio.NewReader(os.Stdin)
for {
fmt.Println("\n1. Add 2. View 3. Remove 4. Quit")
fmt.Print("Choose: ")
choiceLine, err := reader.ReadString('\n')
if err != nil {
return
}
choice := strings.TrimSpace(choiceLine)
switch choice {
case "1":
fmt.Print("New task: ")
taskLine, _ := reader.ReadString('\n')
tasks = addTask(tasks, strings.TrimSpace(taskLine))
saveTasks(tasks)
case "2":
for i, task := range tasks {
fmt.Printf("%d. %s\n", i+1, task)
}
case "3":
fmt.Print("Remove which number? ")
numLine, _ := reader.ReadString('\n')
number, err := strconv.Atoi(strings.TrimSpace(numLine))
if err == nil {
tasks = removeTask(tasks, number)
saveTasks(tasks)
}
case "4":
return
default:
fmt.Println("Please choose 1 to 4.")
}
}
}
json module. json.Marshal turns a Go value into JSON text; json.Unmarshal does the reverse.loadTasks() — try to read the save file with
os.ReadFile. If it does not exist yet (or is not valid JSON), we return an empty slice instead of crashing — this is what makes the very first run work with no file present at all.saveTasks(tasks) — write the current slice to the file as JSON bytes. We call this every time the list changes, exactly like the Python version.
[]string — Go’s slice is the equivalent of Python’s list: a growable, ordered collection.
append(tasks, task) adds to the end, just like Python’s .append().append(tasks[:number-1], tasks[number:]...) — this is how you remove an item from a Go slice: take everything before it, and append everything after it (the
... spreads the second slice out as individual arguments). It looks more explicit than Python’s .pop(), but it is doing the same job. We subtract 1 because people count tasks from 1 but slices count from 0.for { switch choice { ... } } — the menu loop: keep showing the menu until the user picks Quit, which
returns straight out of main.
saveTasks after a change — tasks vanish when you quit.tasks[number] instead of tasks[number-1] when removing — removes the wrong task, or panics if number equals len(tasks).if number >= 1 && number <= len(tasks) before touching the slice.4 Test & Prove Each Part
We test the list operations — adding and removing — directly, without needing the menu or a person typing anything.
package main
import (
"reflect"
"testing"
)
func TestAdd(t *testing.T) {
got := addTask([]string{}, "Buy milk")
want := []string{"Buy milk"}
if !reflect.DeepEqual(got, want) {
t.Errorf("addTask([], \"Buy milk\") = %v; want %v", got, want)
}
}
func TestRemove(t *testing.T) {
got := removeTask([]string{"a", "b"}, 1)
want := []string{"b"}
if !reflect.DeepEqual(got, want) {
t.Errorf("removeTask([a b], 1) = %v; want %v", got, want)
}
}
func TestRemoveBadNumber(t *testing.T) {
got := removeTask([]string{"a"}, 9)
want := []string{"a"}
if !reflect.DeepEqual(got, want) {
t.Errorf("removeTask([a], 9) = %v; want %v (unchanged)", got, want)
}
}
Run with go test -v ./.... The list logic lives in its own small functions (addTask, removeTask) so tests check it directly, without touching a file or a menu. We use reflect.DeepEqual to compare whole slices at once. The bad-number test proves the program will not crash on a wrong input.
5 The Interface
What it expects
Choose: 1
New task: Walk the dogWhat it returns
1. Walk the dog
2. Buy milk6 Run It & Automate It
Save the code as todo.go and run it with go run todo.go — Go compiles and executes in one step, no separate build needed while you are experimenting.
go run todo.goAdd a few tasks, quit, run it again — your tasks are still there (saved in
tasks.json, next to the program).A CI tool like Jenkins runs go test automatically whenever the code changes — every line below has a plain explanation.
1. Add 2. View 3. Remove 4. Quit
Choose: 1
New task: Walk the dog
1. Add 2. View 3. Remove 4. Quit
Choose: 2
1. Walk the dogsaveTasks(tasks) after each change, not just at the end.len(tasks) first. Add the range guard shown above.tasks.json and start fresh — loadTasks already handles a missing file gracefully, but not a half-written one.// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Go') {
steps {
sh 'go version' // confirm Go is installed
sh 'test -f go.mod || go mod init todo_list' // create a module if none exists
}
}
stage('Run the tests') {
steps {
sh 'go vet ./...' // catch obvious mistakes before running
sh 'go test -v ./...' // run every test, show each result
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Mark as done. Switch from
[]stringto a slice of a smallTask struct{Text string; Done bool}instead of removing. (Teaches: structs.) - Due dates. Add a
DueDatefield to that struct. (Teaches: more structured data.) - Sort tasks. Use
sort.Stringsorsort.Sliceto show them alphabetically. (Teaches: sorting.)
switch, and — the important new idea — saving data to disk with encoding/json so it survives between runs. This save-load-modify-save pattern is the simplest form of a database. Related reference: JSON & Encoding, Standard Library.