Build a CLI Task Manager in Go
Build a persistent CLI task manager in Go using structs, file I/O, and JSON.
Introduction
30 Second Summary
Everyone has a to-do list. Whether it's scribbled on a sticky note or buried in an app, keeping track of tasks is something you do every single day.
In this project, you will build a command-line task manager in Go that saves your tasks to a file and lets you add, complete, and delete them straight from your terminal.
What You'll Build
You type a command, your task appears in a clean formatted list. You mark it done, the checkmark sticks. You close the terminal, come back tomorrow, and everything is still there.
By the end of this project, you'll have:
- A working task manager you run from the terminal. You add tasks and watch them instantly appear with a unique ID, timestamp, and pending status.
- Persistent data saved to a JSON file that survives between sessions. Close the program, reopen it, and your tasks are still there.
- A compiled binary (.exe) you can move to any folder on your machine and run without needing Go installed.
- Secret Mission: Add a filter command that shows only pending or only completed tasks.
Do I need to know Go already?
Nope. This project teaches Go from scratch. You just need VS Code installed on your machine.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
You're going to build a command-line task manager in Go that saves your tasks to a JSON file and compiles down to a standalone binary you can run from anywhere on your machine.
Install Go and Set Up Your Editor
Go is a compiled language. That means you need the Go compiler installed on your machine before you can write or run any Go code.
In this step, you will install Go, set up the VS Code extension that gives you autocomplete and error highlighting, then create your project folder and confirm everything works.
In this step, get ready to:
- Install Go 1.26.5 on your machine.
- Set up the VS Code Go extension with all required tools.
- Create your project folder and confirm Go runs your code.
Install Go 1.26.5
Go's compiler turns your code into a standalone binary. You need it installed before anything else.
- Open your browser and go to https://go.dev/dl/.
- Download go1.26.5.windows-amd64.msi (the Windows installer for 64-bit machines).
- Run the installer and accept all the defaults.
What does the installer do?
The MSI installer places the Go compiler on your machine and adds it to your system PATH. The PATH update means your terminal can find the go command. You need to open a new terminal window after installation for the change to take effect.
Now verify the installation. You need a fresh terminal window because the PATH was just updated.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open a new PowerShell window.
- Verify Go is installed by running this command:
go version
✔️ I see go1.26.5 or higher
You should see:
go version go1.26.5 windows/amd64
Go is installed and ready. Move on to the next substep.
ⓧ I see an older version
You have a previous version of Go installed. The MSI installer will upgrade it in place.
- Go to https://go.dev/dl/ and download go1.26.5.windows-amd64.msi.
- Run the installer and accept all defaults. It replaces your existing Go version.
- Close this PowerShell window and open a brand new one.
- Run go version again. You should now see go1.26.5.
ⓧ Command not found
The most common cause is using a terminal window that was already open before you ran the installer. The PATH update only applies to new windows.
- Close this PowerShell window completely.
- Open a brand new PowerShell window (press the Windows key, type PowerShell, press Enter).
- Run go version again.
If it still fails, re-run the go1.26.5.windows-amd64.msi installer and make sure it completes without errors.
Install the VS Code Go extension
The Go extension adds autocomplete, inline error highlighting, and automatic formatting every time you save a file. It makes writing Go in VS Code feel seamless.
- Switch back to VS Code.
- Press Ctrl+Shift+X to open the Extensions sidebar.
- Type Go in the search bar.
- Find the extension named Go by Go Team at Google (ID: golang.Go) and click Install.
The extension needs helper tools to provide features like autocomplete and formatting. Install them now.
- Open the Command Palette by pressing Ctrl+Shift+P.
- Type Go: Install/Update Tools and select it from the dropdown.
- Select all tools in the list (click the checkbox at the top) and click OK.
- Wait for the output panel to show that all tools installed successfully.
Tools failing to install?
Make sure the PowerShell window where you verified go version is still open and that go version still works. VS Code uses the same PATH. If you installed Go after opening VS Code, close VS Code completely and reopen it so it picks up the updated PATH.
Why can't VS Code find my Go installation?
Create your project
Every Go project starts with a module. A module is Go's way of organizing your code and tracking dependencies.
- Switch back to your PowerShell window.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and initialize a Go module by running these commands:
mkdir task-manager
cd task-manager
go mod init task-manager
- Confirm the folder and module file exist by running:
ls
You should see go.mod listed in the output. This file tracks your module name and Go version.
What is go.mod?
The go mod init task-manager command creates a go.mod file that declares your module's name (task-manager) and the Go version it was built with. Think of it like a project ID card that Go reads every time you build or run your code.
- Open this folder in VS Code by running:
code .
A new VS Code window will open
The code . command opens a new VS Code window pointed at your task-manager folder. You can close the old VS Code window.
Now create your first Go file. This is the entry point for your program.
- In the VS Code Explorer sidebar (left panel), click the New File icon.
- Name the file main.go and press Enter.
- Paste the following code into main.go:
package main
import "fmt"
func main() {
fmt.Println("Task Manager ready!")
}
What does this code do?
Every Go program starts with package main. This tells Go that this file is the entry point for a runnable program.
The import "fmt" line brings in Go's formatting package, which provides the Println function for printing to the terminal. The func main() function runs automatically when you execute the program.
- Save the file with Ctrl+S.
Before you run this, what do you think the output will be?
- Open the terminal in VS Code by pressing Ctrl+` (backtick key, usually above Tab).
- Run your program with this command:
go run .
You should see Task Manager ready! printed in your terminal. That confirms Go is compiling and running your code.
Don't see the output?
Make sure your terminal is in the task-manager folder. Run pwd to check your current directory. If you are somewhere else, run cd ~/Desktop/task-manager.
If you see a syntax error, check that your code in main.go matches the code above exactly (including the quotes around fmt).
Help me debug why go run . is not working
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.go in VS Code.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point:
go.mod:
module task-manager
go 1.26.5
main.go:
package main
import "fmt"
func main() {
fmt.Println("Task Manager ready!")
}
Your environment is ready. You have Go installed, VS Code configured with the Go extension, and a running project. Next up, you will define what a task actually looks like in Go using structs.
Define Your Task Struct
Your Go environment is ready and your project runs its first line of output. Now it's time to teach your program what a task actually looks like.
In Go, you define the shape of your data using a struct. A struct declares exactly what fields a task holds and what types those fields have. This is where your task manager stops being a "hello world" and starts modelling real data.
In this step, get ready to:
- Define a Task struct with typed fields and JSON tags.
- Create sample tasks and display them in the terminal.
Define the Task struct
A struct is a blueprint that groups related fields together under one name. Each field has a name and a type, so Go knows exactly what kind of data it can hold.
- Replace the entire contents of main.go with this code:
package main
import "fmt"
// Task represents a single to-do item
type Task struct {
ID int `json:"id"`
Name string `json:"name"`
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
}
func main() {
// Create a sample task to test the struct
task := Task{
ID: 1,
Name: "Buy groceries",
Done: false,
CreatedAt: "2024-01-01",
}
// Print the task with field names visible
fmt.Printf("%+v\n", task)
}
What does this code do?
The Task struct defines four fields: an ID (integer), a Name (string), a Done flag (boolean), and a CreatedAt date (string).
The backtick annotations like `json:"id"` are struct tags. They tell Go's JSON encoder what name to use when saving tasks to a file later.
The %+v format verb prints a struct with its field names visible, so you can see exactly what data your task holds.
- Save main.go.
- Run your program by entering this command in the terminal:
go run .
You should see your sample task printed with all field names and values:
{ID:1 Name:Buy groceries Done:false CreatedAt:2024-01-01}
Seeing a syntax error?
- Check that each struct tag uses backticks (`) and not regular quotes. Backticks are the key to the left of the 1 key on most keyboards.
- Make sure each field in the Task{} literal ends with a comma, including the last one (CreatedAt). Go requires trailing commas in multi-line struct literals.
- Confirm the import "fmt" line is present at the top of your file.
help me fix a syntax error in my Go struct definition
Create a slice of tasks and display them
A single task proves the struct works. Now you need a collection of tasks. In Go, a slice is a growable list that holds multiple values of the same type.
- In main.go, find the func main() function.
- Replace the entire main() function with this updated version:
func main() {
// Create a slice of sample tasks
tasks := []Task{
{ID: 1, Name: "Buy groceries", Done: false, CreatedAt: "2024-01-01"},
{ID: 2, Name: "Learn Go structs", Done: true, CreatedAt: "2024-01-02"},
{ID: 3, Name: "Build CLI app", Done: false, CreatedAt: "2024-01-03"},
}
// Loop through each task and print it
for _, task := range tasks {
fmt.Printf("%+v\n", task)
}
}
What does this code do?
The []Task syntax declares a slice (a growable list) that holds Task values. You populate it with three sample tasks using the short struct literal syntax.
The for _, task := range tasks loop walks through every task in the slice. The underscore (_) discards the index because you only need the task value itself.
- Save main.go.
Before you run this, predict what you expect to see in the terminal. How many lines of output will there be?
- Run your program:
go run .
You should see all three tasks printed, each on its own line with field names and values:
{ID:1 Name:Buy groceries Done:false CreatedAt:2024-01-01}
{ID:2 Name:Learn Go structs Done:true CreatedAt:2024-01-02}
{ID:3 Name:Build CLI app Done:false CreatedAt:2024-01-03}
Only seeing one task or no output?
- Make sure you replaced the entire main() function, not just added code below the old one. You should have only one func main() in the file.
- Check that each task in the slice ends with a closing brace and comma: }, including the last task before the closing } of the slice.
- Verify the for loop is inside main(), after the slice definition.
help me debug my Go for loop that prints tasks
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and your output shows all three tasks.
ⓧ I'd like to double check the full code
Here is the complete main.go file as it should look at the end of this step:
package main
import "fmt"
type Task struct {
ID int `json:"id"`
Name string `json:"name"`
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
}
func main() {
tasks := []Task{
{ID: 1, Name: "Buy groceries", Done: false, CreatedAt: "2024-01-01"},
{ID: 2, Name: "Learn Go structs", Done: true, CreatedAt: "2024-01-02"},
{ID: 3, Name: "Build CLI app", Done: false, CreatedAt: "2024-01-03"},
}
for _, task := range tasks {
fmt.Printf("%+v\n", task)
}
}
You have a working data model and sample data printing in your terminal. Next up, you will save those tasks to a file so they survive between program runs.
Save and Load Tasks from a JSON File
You have a working Task struct and a program that prints sample tasks. There is one problem though. Every time the program exits, those tasks vanish.
Real task managers remember your data between sessions. In this step, you will write two functions that save tasks to a JSON file and load them back. Go's encoding/json and os packages make this surprisingly easy.
In this step, get ready to:
- Write a saveTasks function to persist tasks to a JSON file.
- Write a loadTasks function to read tasks back from the file.
- Verify persistence works across multiple program runs.
Write a saveTasks function
Your program needs a way to convert its in-memory tasks into a format that survives on disk. JSON is a natural choice because it is human-readable and Go has built-in support for it.
- In main.go, find the import "fmt" line at the top. Replace it with this multi-import block, then paste the constant and function between your Task struct's closing brace and func main():
import (
"encoding/json"
"fmt"
"os"
)
// (paste the following between your Task struct and func main)
const tasksFile = "tasks.json"
func saveTasks(tasks []Task) error {
// Convert the tasks slice to formatted JSON
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return err
}
// Write the JSON data to the file
return os.WriteFile(tasksFile, data, 0644)
}
What does this code do?
json.MarshalIndent converts your Go slice into a nicely formatted JSON string with 2-space indentation. The error return value tells you if something went wrong during conversion.
os.WriteFile saves that JSON string to a file called tasks.json. The 0644 sets file permissions so you (the owner) can read and write it.
- Now add this code inside func main(), between the hardcoded slice and the for loop:
err := saveTasks(tasks)
if err != nil {
fmt.Println("Error saving tasks:", err)
return
}
What is this error-handling pattern?
In Go, functions that can fail return an error value. The pattern is always the same: call the function, check if err != nil, and handle the failure. You will see this pattern everywhere in Go code.
Before you run this, what do you think will happen in your project folder?
- Save main.go and run your program:
go run .
You should see your three tasks printed as before. Now check your project folder:
- Confirm tasks.json was created by running:
type tasks.json
You should see your three tasks formatted as a JSON array with indentation.
Don't see tasks.json?
- Make sure you saved main.go before running. Unsaved changes won't compile.
- Check that the saveTasks(tasks) call appears AFTER the slice is created but BEFORE the for loop.
- If you see a compile error, confirm your import block has all three packages: encoding/json, fmt, and os.
Why isn't my tasks.json file appearing after I run the program?
Write a loadTasks function
Now your tasks are saved to disk. You need the other half of persistence: a function that reads the file and turns the JSON back into Go data.
- In main.go, paste this function between the const tasksFile line and your saveTasks function:
func loadTasks() ([]Task, error) {
data, err := os.ReadFile(tasksFile)
if err != nil {
// If the file doesn't exist yet, return an empty slice
if os.IsNotExist(err) {
return []Task{}, nil
}
return nil, err
}
var tasks []Task
err = json.Unmarshal(data, &tasks)
if err != nil {
return nil, err
}
return tasks, nil
}
What does this code do?
- os.ReadFile(tasksFile) reads the entire file into a byte slice.
- os.IsNotExist(err) checks if the error means the file simply does not exist yet. In that case, you return an empty slice instead of crashing.
- json.Unmarshal(data, &tasks) parses the JSON bytes into your []Task slice. The & passes a pointer so Unmarshal can fill in the data.
- Now add a loadTasks() call at the very beginning of func main(), before the hardcoded slice. Add these lines as the first thing inside main():
// Load any previously saved tasks
loaded, err := loadTasks()
if err != nil {
fmt.Println("Error loading tasks:", err)
return
}
fmt.Println("Loaded from file:", len(loaded), "tasks")
- Save main.go and run:
go run .
You should see Loaded from file: 3 tasks followed by your three tasks printing. The file you created in the previous substep is being read back successfully.
Seeing 0 tasks loaded?
- Confirm tasks.json still exists in your project folder. If you accidentally deleted it, run the program once without the loadTasks call to regenerate it.
- Check that your loadTasks function references the same tasksFile constant (not a hardcoded string).
Help me figure out why loadTasks returns 0 tasks even though tasks.json exists.
Verify persistence across runs
You have proven the round-trip works: save to file, load from file. Now remove the training wheels. Your program should rely entirely on the JSON file for its data.
- In main.go, find the func main() function. Delete everything inside it and replace it with this:
func main() {
tasks, err := loadTasks()
if err != nil {
fmt.Println("Error loading tasks:", err)
return
}
for _, task := range tasks {
fmt.Printf("%+v\n", task)
}
}
What changed?
You removed the hardcoded sample tasks and the saveTasks call. Now main() does only two things: load tasks from the file, and print them. The program is fully driven by the JSON file.
What do you think will happen when you run this? The hardcoded tasks are gone, but tasks.json still exists from your earlier run.
- Save main.go and run:
go run .
You should see your three tasks printed, loaded entirely from the file. No hardcoded data anywhere in your code.
- Now test what happens when the file is missing. Delete tasks.json by running:
del tasks.json
- Run the program again:
go run .
The program runs without crashing and prints nothing. Your os.IsNotExist check handles the missing file gracefully by returning an empty slice.
Seeing an error instead of empty output?
- If you see a file-related error, confirm that your loadTasks function has the os.IsNotExist(err) check inside the first if err != nil block.
- Make sure you are checking os.IsNotExist(err) not os.IsNotExist(data). The argument must be the error value.
Why does my program crash when tasks.json doesn't exist?
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go with the final version that only loads and prints.
ⓧ I'd like to double check the full code
Here is what your main.go should look like at this point:
package main
import (
"encoding/json"
"fmt"
"os"
)
type Task struct {
ID int `json:"id"`
Name string `json:"name"`
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
}
const tasksFile = "tasks.json"
func loadTasks() ([]Task, error) {
data, err := os.ReadFile(tasksFile)
if err != nil {
if os.IsNotExist(err) {
return []Task{}, nil
}
return nil, err
}
Scroll up and confirm each section is present in order: the import block with three packages, the Task struct, the tasksFile constant, the loadTasks function, the saveTasks function, and main() calling only loadTasks and printing.
Your program now persists data to disk and loads it back. Next up, you will add real command-line actions so you can add, list, complete, and delete tasks without touching the code.
Add Command-Line Actions
Your tasks now survive between program runs. The persistence problem is solved.
The next problem is more fundamental. Your program does the same thing every time you run it. A real CLI tool responds to different commands like add, list, done, and delete. In Go, os.Args gives you access to command-line arguments as a slice of strings.
In this step, get ready to:
- Parse command-line arguments with os.Args.
- Implement add and list commands.
- Implement done and delete commands with error handling.
Parse os.Args and implement "add" and "list"
Right now, main() always loads and prints tasks the same way. You need it to look at what the user typed after go run . and act accordingly.
- In main.go, find the import block at the top:
import (
"encoding/json"
"fmt"
"os"
)
- Replace it with this updated import block that adds strconv (for converting strings to numbers) and time (for timestamps):
import (
"encoding/json"
"fmt"
"os"
"strconv"
"time"
)
Why two new packages?
The strconv package converts strings to integers. You need this because command-line arguments always arrive as strings, even when the user types a number like a task ID.
The time package gives you the current date to timestamp each new task.
Before you can add tasks, you need a way to generate unique IDs. If a user adds three tasks, deletes task #2, then adds another, the new task should be #4 (not #3 again).
- Add this getNextID function between your saveTasks function and your main function:
func getNextID(tasks []Task) int {
// Find the highest existing ID
maxID := 0
for _, task := range tasks {
if task.ID > maxID {
maxID = task.ID
}
}
return maxID + 1
}
Why not just use the slice length?
Using len(tasks) + 1 seems simpler, but it breaks after deletions. If you have tasks 1, 2, 3 and delete task 2, the slice length is 2. The next ID would be 3, colliding with the existing task #3.
Finding the max ID and adding 1 guarantees uniqueness no matter how many tasks are deleted.
Now replace your entire main() function. The new version reads the first argument to decide which command to run.
- Delete your existing main() function (the one that loads and prints tasks with fmt.Printf) and replace it with this structure:
func main() {
// Check if user provided a command
if len(os.Args) < 2 {
fmt.Println("Usage: task-manager <command>")
return
}
// First argument after the program name is the command
command := os.Args[1]
switch command {
}
}
What is os.Args?
os.Args is a slice of strings holding everything the user typed. When you run go run . add "Buy milk", os.Args looks like this:
- os.Args[0] = the program path.
- os.Args[1] = "add" (the command).
- os.Args[2] = "Buy milk" (the argument).
- Save main.go and test the empty structure by running:
go run .
You should see Usage: task-manager <command> printed, since you ran with no arguments.
Seeing a compile error?
If you see imported and not used errors for strconv or time, that is expected at this point. Go does not allow unused imports. You can temporarily comment out those two import lines and uncomment them in the next chunk when you add the add case.
help me fix unused import errors in Go
Now fill in the first command. The add case creates a new task with a unique ID, appends it to the slice, and saves to disk.
- Inside the switch command { block (between the opening brace and the closing brace), add this add case:
case "add":
if len(os.Args) < 3 {
fmt.Println("Error: Please provide a task name.")
return
}
tasks, err := loadTasks()
if err != nil {
fmt.Println("Error loading tasks:", err)
return
}
// Create a new task with unique ID and today's date
task := Task{
ID: getNextID(tasks),
Name: os.Args[2],
Done: false,
CreatedAt: time.Now().Format("2006-01-02"),
}
tasks = append(tasks, task)
err = saveTasks(tasks)
if err != nil {
fmt.Println("Error saving tasks:", err)
return
}
fmt.Printf("Added task #%d: %s\n", task.ID, task.Name)
What does this code do?
The if len(os.Args) < 3 check ensures the user actually typed a task name after add. Without it, the program would crash trying to access os.Args[2] on a missing element.
The time.Now().Format("2006-01-02") line uses Go's unique reference-time pattern. Go uses the specific date "Mon Jan 2 15:04:05 2006" as its formatting template, so 2006-01-02 means year-month-day.
- Save main.go and test the add command by running:
go run . add "Learn Go structs"
You should see Added task #1: Learn Go structs printed.
Seeing "Error: Please provide a task name"?
Make sure you wrap your task name in quotes. Without quotes, the shell treats each word as a separate argument. go run . add Learn Go structs passes Learn as os.Args[2] and ignores the rest.
help me fix the add command in my Go task manager
Now add the list command so you can see your tasks. This loads all tasks from the file and displays them with a checkbox indicator.
- Add this list case directly below the add case (still inside the switch block):
case "list":
tasks, err := loadTasks()
if err != nil {
fmt.Println("Error loading tasks:", err)
return
}
// Display each task with a checkbox-style status
for _, task := range tasks {
status := "[ ]"
if task.Done {
status = "[x]"
}
fmt.Printf("%d %s %s\n", task.ID, status, task.Name)
}
What does this code do?
The list case loads tasks from tasks.json, then loops through each one. For each task, it picks either [ ] (pending) or [x] (done) as the status indicator, then prints the ID, status, and name on one line.
- Save main.go and list your tasks by running:
go run . list
You should see your task displayed: 1 [ ] Learn Go structs.
Seeing no output at all?
Check that tasks.json exists in your project folder. The add command creates it. If you never ran add successfully, the file might be empty or missing.
help me debug why list shows nothing in my Go task manager
Implement "done" and "delete"
You can add and list tasks. Now you need to mark them as complete and remove them. Both commands take a task ID as an argument and need to convert that string to a number using strconv.Atoi.
- Add this done case below the list case. First, the argument validation and task loading:
case "done":
if len(os.Args) < 3 {
fmt.Println("Error: Please provide a task ID.")
return
}
id, err := strconv.Atoi(os.Args[2])
if err != nil {
fmt.Println("Error: ID must be a number.")
return
}
tasks, err := loadTasks()
if err != nil {
fmt.Println("Error loading tasks:", err)
return
}
- Directly below that code (still inside the done case), add the logic to find and update the task:
// Search for the task by ID and mark it done
found := false
for i, task := range tasks {
if task.ID == id {
tasks[i].Done = true
found = true
break
}
}
if !found {
fmt.Printf("Error: No task found with ID %d.\n", id)
return
}
err = saveTasks(tasks)
if err != nil {
fmt.Println("Error saving tasks:", err)
return
}
fmt.Printf("Task #%d marked as done.\n", id)
What does this code do?
strconv.Atoi(os.Args[2]) converts the string "1" into the integer 1. If the user types something that is not a number (like go run . done abc), Atoi returns an error and the program prints a helpful message.
The loop uses tasks[i].Done = true instead of task.Done = true because task in a Go for range loop is a copy. You must index into the slice to modify the original.
- Save main.go and test the done command:
go run . done 1
You should see Task #1 marked as done. printed.
- Confirm it worked by running:
go run . list
Your task now shows [x] instead of [ ].
The delete command follows the same pattern but removes a task from the slice entirely instead of updating it.
- Add the delete case below done. First, the argument parsing and loading:
case "delete":
if len(os.Args) < 3 {
fmt.Println("Error: Please provide a task ID.")
return
}
id, err := strconv.Atoi(os.Args[2])
if err != nil {
fmt.Println("Error: ID must be a number.")
return
}
tasks, err := loadTasks()
if err != nil {
fmt.Println("Error loading tasks:", err)
return
}
- Directly below that code, add the deletion logic and a default case for unknown commands:
// Build a new slice excluding the deleted task
newTasks := []Task{}
found := false
for _, task := range tasks {
if task.ID == id {
found = true
continue
}
newTasks = append(newTasks, task)
}
if !found {
fmt.Printf("Error: No task found with ID %d.\n", id)
return
}
err = saveTasks(newTasks)
if err != nil {
fmt.Println("Error saving tasks:", err)
return
}
fmt.Printf("Task #%d deleted.\n", id)
default:
fmt.Printf("Unknown command: %s\n", command)
How does deletion work without a built-in remove?
Go slices do not have a built-in "remove" method. Instead, you build a brand new slice (newTasks) by looping through the original and skipping the task you want to delete using continue. Every other task gets appended to the new slice.
The default case catches any command that does not match add, list, done, or delete and prints a message so the user knows they mistyped.
- Save main.go. First, add a second task so you have something to delete:
go run . add "Practice error handling"
- Now delete the first task by running:
go run . delete 1
You should see Task #1 deleted. printed.
- Confirm the deletion by listing tasks:
go run . list
Only task #2 remains in the list.
Seeing "Error: No task found with ID 1"?
If you already deleted tasks.json earlier or ran done 1 on an empty file, re-add tasks first with go run . add "Test task". The IDs in your file must match the ID you pass to the command.
help me debug task not found errors in my Go CLI
Before you move on, do a full round-trip test. What do you expect to see when you add a new task, then immediately list all tasks?
- Test the complete flow by running:
go run . add "Ship my first Go app"
go run . list
You should see both remaining tasks listed with their IDs, statuses, and names.
- Now mark the new task as done and verify:
go run . done 3
go run . list
The task you just completed now shows [x] as its status. All four commands work.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go.
ⓧ I'd like to double check the full code
Your main.go should have these sections in order. Scroll up and confirm each is present:
- Import block with encoding/json, fmt, os, strconv, time.
- Task struct with JSON tags.
- const tasksFile declaration.
- loadTasks() function.
- saveTasks() function.
- getNextID() function.
- main() with os.Args check, then a switch containing: add, list, done, delete, default.
Your task manager now responds to four different commands. Next up, you will polish the output formatting and compile the whole thing into a standalone binary you can run from anywhere.
Build a Binary and Polish the Output
Your task manager handles all four commands. You can add, list, mark done, and delete tasks from the terminal.
The output is basic though. A real CLI tool has formatted columns so you can scan tasks at a glance, plus a help message so you remember the commands. And the real power of Go is that you can compile everything into a single .exe file that runs anywhere without Go installed.
In this step, get ready to:
- Add formatted table output and a help message.
- Compile your program to a standalone binary.
Add formatted output and help
Right now the list command dumps tasks in a plain format with no alignment. You will extract the display logic into a dedicated function that prints a clean table.
- In main.go, find the closing brace of the getNextID function.
- Add this new printTasks function below it:
func printTasks(tasks []Task) {
if len(tasks) == 0 {
fmt.Println("No tasks yet. Add one with: task-manager add \"your task\"")
return
}
fmt.Printf("%-4s %-6s %-30s %s\n", "ID", "Status", "Name", "Created")
fmt.Println("---- ------ ------------------------------ ----------")
for _, task := range tasks {
status := "[ ]"
if task.Done {
status = "[x]"
}
fmt.Printf("%-4d %-6s %-30s %s\n", task.ID, status, task.Name, task.CreatedAt)
}
}
What does this code do?
This function handles displaying tasks in two scenarios:
- If the slice is empty, it prints a helpful hint telling you how to add your first task.
- The %-4s and %-30s format strings are Go's way of left-aligning text in columns of fixed width. This creates neat table-like output.
- The separator line (dashes) goes between the header and the data rows for readability.
Now you need to use this function. The list case currently has an inline for loop that prints tasks in a plain format.
- In the case "list": block, find these lines:
for _, task := range tasks {
status := "[ ]"
if task.Done {
status = "[x]"
}
fmt.Printf("%d %s %s\n", task.ID, status, task.Name)
}
- Replace that entire for loop with a single call to your new function:
printTasks(tasks)
- Save main.go and run your list command to see the formatted table:
go run . list
You should see your tasks displayed in aligned columns with headers for ID, Status, Name, and Created.
Seeing a compile error?
Make sure the printTasks function is placed outside of func main(). It should sit between getNextID and main as its own top-level function.
Also check that you deleted the entire old for loop from the list case. Leftover lines will cause duplicate variable errors.
Help me fix a compile error in my printTasks function
Next, you will add a help message so users know what commands are available.
- Below your printTasks function, add this printUsage function:
func printUsage() {
fmt.Println("Task Manager - A simple CLI todo app")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" task-manager add \"task name\" Add a new task")
fmt.Println(" task-manager list List all tasks")
fmt.Println(" task-manager done <id> Mark a task as complete")
fmt.Println(" task-manager delete <id> Delete a task")
fmt.Println()
}
What does this code do?
This prints a short manual showing the program name and every available command with its syntax. Users see this whenever they run the program without arguments or type an unrecognized command.
Now wire up printUsage so it actually gets called.
- At the top of func main(), find the no-arguments handler:
if len(os.Args) < 2 {
fmt.Println("Usage: task-manager <command>")
return
}
- Replace fmt.Println("Usage: task-manager <command>") with printUsage() so the block now reads:
if len(os.Args) < 2 {
printUsage()
return
}
- Save main.go and test the help message by running with no arguments:
go run .
You should see the full usage guide listing all four commands with their syntax.
Still seeing the old message?
Double-check you replaced the entire fmt.Println("Usage: task-manager <command>") line. If both lines exist you will get the old output followed by the new one.
Help me fix my printUsage function not showing
Two more quick edits to tie everything together. You will update the default case to show the help when someone types an unknown command, and add usage hints to your error messages.
- Find the default: case at the bottom of the switch block:
default:
fmt.Printf("Unknown command: %s\n", command)
- Replace it with this version that adds an extra newline and calls printUsage():
default:
fmt.Printf("Unknown command: %s\n\n", command)
printUsage()
- Next, add a usage hint line to the error handler in each of the add, done, and delete cases. In the add case, find:
if len(os.Args) < 3 {
fmt.Println("Error: Please provide a task name.")
return
}
- Add a usage hint line so it looks like this:
if len(os.Args) < 3 {
fmt.Println("Error: Please provide a task name.")
fmt.Println("Usage: task-manager add \"task name\"")
return
}
- Do the same for the done case. Add this line after the "Error: Please provide a task ID." print:
fmt.Println("Usage: task-manager done <id>")
- And the same for the delete case:
fmt.Println("Usage: task-manager delete <id>")
- Save main.go and verify the usage hints work by running the add command without a task name:
go run . add
You should see the error message followed by the correct usage syntax for the add command.
✔️ Awesome, I've got everything!
Great. Double-check you have saved main.go with all changes.
ⓧ I'd like to double check the full code
Scroll back through the chunks you pasted and confirm each one is present in order:
- The printTasks function sits between getNextID and printUsage.
- The printUsage function sits between printTasks and func main().
- The no-args check at the top of main() calls printUsage().
- The list case calls printTasks(tasks) instead of the inline for loop.
- The add, done, and delete error handlers each have a usage hint line.
- The default case prints the unknown command then calls printUsage().
Compile to a binary
Every time you have been running your program with go run ., Go compiles your code into a temporary binary, runs it, then throws it away. The go build command compiles once and produces a permanent executable you can move anywhere.
- Compile your project into a standalone binary by running:
go build -o task-manager.exe .
What does this command do?
- go build compiles all .go files in the current directory.
- -o task-manager.exe names the output file. Without -o it would default to the module name.
- The resulting .exe has no dependencies. It runs on any Windows machine without Go installed.
- Confirm the binary was created by running:
ls
You should see task-manager.exe listed alongside main.go, go.mod, and tasks.json.
Before you test the binary, what do you think will happen when you run it? Will it behave any differently from go run .?
- Test the binary by adding a task and listing all tasks:
.\task-manager.exe add "Ship it"
.\task-manager.exe list
The output is identical to go run .. You see your new task in the formatted table alongside any tasks from earlier steps. The difference is that this binary is a standalone program that does not need the Go toolchain to run.
Permission denied or command not recognized?
Make sure you include the .\ prefix when running the binary. Without it, PowerShell looks for the command in your system PATH rather than the current directory.
If you see a security warning, click **More info** then **Run anyway**. Windows sometimes flags newly compiled executables.
Help me run my compiled Go binary
You now have a fully functional CLI tool compiled into a single file. In the Secret Mission, you will push your skills further by adding a filter command that shows only pending or completed tasks.
Secret mission
Add a Filter Command
Your task manager lists all tasks at once. What if you have dozens of tasks and want to see only pending ones? Add a filter command that shows a subset of your tasks based on their status.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- task-manager project folder on your Desktop (contains main.go, go.mod, tasks.json, and task-manager.exe).
- tasks.json file storing your persisted task data (local file, no cost).
- task-manager.exe compiled binary (local file, no cost).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep using your task manager.
- Your task-manager folder stays on your Desktop with all source code and the compiled binary.
- You can continue running .\task-manager.exe from anywhere by providing the full path, or by copying the binary to a folder in your system PATH.
- Go and the VS Code Go extension remain installed on your machine. They take up disk space but have no ongoing cost.
Pause - I'll come back to this later
Close any running processes to free up memory. Your files stay on disk for when you return.
- Close the PowerShell terminal window you used for this project.
- Close the task-manager folder in VS Code if it is still open.
- Your project files remain on your Desktop. Open the folder in VS Code again whenever you want to continue.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- Close VS Code if the project folder is still open.
- Delete the entire project folder by running this command in PowerShell:
Remove-Item -Recurse -Force ~/Desktop/task-manager
- Confirm the folder is gone by running:
ls ~/Desktop/task-manager
You should see an error indicating the path does not exist. That confirms the deletion worked.
What about Go itself?
Go and the VS Code Go extension remain installed on your machine. If you want to remove Go, open Settings, select Apps, find Go Programming Language in the list, and click Uninstall.
To remove the VS Code extension, press Ctrl+Shift+X in VS Code, find the Go extension, and click Uninstall.
Nice Work!
Nice Work!
You just built a fully functional CLI task manager in Go from scratch. Your program adds, lists, completes, and deletes tasks, all persisted to a JSON file and compiled into a standalone binary you can run anywhere.
You've learned how to:
- Structure a Go program using structs, slices, and functions to model real data and build a working CLI tool from scratch.
- Apply Go's error handling pattern (returning errors, checking with if err != nil) alongside file I/O and JSON encoding/decoding for persistent data storage.
- Parse command-line arguments with os.Args and compile your project into a standalone binary with go build that runs without Go installed on the target machine.
- Secret Mission: Added a filter command that queries a subset of tasks by status, combining argument parsing with conditional slice filtering.
Ready to quiz yourself?