← thecodex.expert · The Codex Family of Knowledge
Tier 1 · Beginner · Go Project

To-Do List

Add, view, and remove tasks — and save them so they are still there next time. Your first app that remembers things between runs.

🧠 Teaches how to think spoonfed, every age Last verified:

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.

Where this shows up: every app that stores your stuff — notes, reminders, shopping lists, saved games, settings. The pattern of “keep a list in memory, save it to a file, load it back next time” is the simplest form of a database.

2 How to Think About It

Think about the loop the program lives in, before any code:

The plan — in plain English
1. Load any saved tasks from a file when the program starts. → 2. Show a menu (add / view / remove / quit) and repeat until they quit. → 3. Each choice changes the list. → 4. Save the list back to the file whenever it changes, so nothing is lost.

Add

View

Remove

Quit

Load tasks from file

Show menu

Choice?

Add task

Show tasks

Remove task

Stop

Save to file

3 The Build — explained part by part

Here is the complete to-do list. Each part is explained below.

Gotodo.go
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.")
		}
	}
}
⚠ No in-browser playground here
Go compiles to a real binary, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once Go is installed.
What each part does — in plain words
encoding/json — Go’s built-in JSON package, the equivalent of Python’s 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.
Common mistakes — and how to avoid them
✗ Forgetting to call saveTasks after a change — tasks vanish when you quit.
✓ Save right after every add and remove, exactly as the code above does.
✗ Writing tasks[number] instead of tasks[number-1] when removing — removes the wrong task, or panics if number equals len(tasks).
✓ Slices count from 0 but people count from 1, so subtract 1 — and always check the bound first.
✗ Not checking the number is in range before slicing — removing item 9 of a 2-item slice panics with “index out of range”.
✓ Guard with 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.

Adding a task puts it in the slice
Removing a task takes it out
Removing an out-of-range number does nothing bad
Gotodo_test.go
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

INPUTmenu choice1-4, then task text or number
What it expects
Choose: 1
New task: Walk the dog
OUTPUTtask listnumbered, saved to tasks.json
What it returns
1. Walk the dog
2. Buy milk

6 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.

Run it locally
go run todo.go
Add 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.

What you should see when it works
Terminala real run
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 dog
If it breaks — how to fix it
🚨 Tasks disappear after quitting.
You are not saving. Call saveTasks(tasks) after each change, not just at the end.
🚨 panic: runtime error: index out of range
You removed a task number without checking it is between 1 and len(tasks) first. Add the range guard shown above.
🚨 unexpected end of JSON input on start
The save file is empty or corrupted. Delete tasks.json and start fresh — loadTasks already handles a missing file gracefully, but not a half-written one.
GroovyJenkinsfile
// 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.' }
    }
}
🎯 Try this next — make it yours
  1. Mark as done. Switch from []string to a slice of a small Task struct{Text string; Done bool} instead of removing. (Teaches: structs.)
  2. Due dates. Add a DueDate field to that struct. (Teaches: more structured data.)
  3. Sort tasks. Use sort.Strings or sort.Slice to show them alphabetically. (Teaches: sorting.)
What you learned
You learned slices (Go’s lists), a menu loop with 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.