Saving to Disk
Save Tasks to a File
Goal
By the end of this lesson, Tasker remembers your tasks. The shell loads tasks from a JSON file when it starts and saves them after every change. When you quit and start again, your tasks are still there.
What you will learn
- How to read and write files with the
ospackage - How to build file paths that work on every operating system
- How to handle a file that does not exist yet
- How to wrap errors with context using
%w, and why%vis not enough - How
deferruns cleanup code when a function ends - How to write a file safely, so a crash cannot destroy your data
- How to read an environment variable
Why this matters
Almost every real program stores data somewhere: in files, in a database, or in another service. Each of these can fail in ways that your code does not control. The disk can be full, a folder can be missing, or another program can change the file. In this lesson, you learn to handle these failures clearly, and to report them with enough context that a user can fix the problem.
Step 1: Wrapping errors with %w
When an error travels up through several functions, each function should add a little context. Then the final message tells the whole story, for example:
load /home/ana/.tasker/tasks.json: task 1: title is empty
You can read it from left to right: loading the file failed, because task 1 is invalid, because its title is empty.
In Lesson 3.2, LoadJSON added context with %v:
return fmt.Errorf("task %d: %v", t.ID, err)
There is a problem with this. Try it in play/main.go:
package main
import (
"errors"
"fmt"
"tasker/task"
)
func main() {
withV := fmt.Errorf("task 1: %v", task.ErrEmptyTitle)
withW := fmt.Errorf("task 1: %w", task.ErrEmptyTitle)
fmt.Println(errors.Is(withV, task.ErrEmptyTitle), errors.Is(withW, task.ErrEmptyTitle))
fmt.Println(errors.Unwrap(withW) == task.ErrEmptyTitle)
}
Output:
false true
true
Both errors print the same message, but they are different inside:
%vcopies the text of the error into a new error. The original error is lost, soerrors.Iscannot find it.%w("wrap") keeps the original error inside the new one.errors.Islooks through every layer of wrapping and finds it.errors.Unwrapreturns the error one layer down.
Rule: when you add context to an error, use %w. Now open task/store.go and change the line in LoadJSON:
return fmt.Errorf("task %d: %w", t.ID, err)
This is why you used errors.Is instead of == in Lesson 2.4. errors.Is still works after wrapping.
Step 2: Load tasks from a file
Create task/file.go:
package task
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// Load reads tasks from the JSON file at path.
// If the file does not exist yet, the store stays empty and Load returns no error.
func (s *Store) Load(path string) error {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read task file: %w", err)
}
if err := s.LoadJSON(data); err != nil {
return fmt.Errorf("load %s: %w", path, err)
}
return nil
}
os.ReadFilereads a whole file and returns its content as[]byte. That is exactly whatLoadJSONneeds.errors.Is(err, os.ErrNotExist)checks if the file is missing. The first time you use Tasker, there is no file yet, and that is normal. SoLoadreturnsniland the store stays empty. Here, the standard library uses the same sentinel error pattern that you used in Lesson 2.4.- Other read errors, such as missing permission to read the file, are real problems.
Loadwraps them with context and returns them.
Step 3: Save tasks safely
Imagine Tasker writes directly to tasks.json, and the computer loses power in the middle of the write. The file now has half of the JSON. On the next start, LoadJSON fails, and all your tasks are lost.
Programs avoid this with a simple trick: write the data to a temporary file first, then rename it over the real file. A rename is a single, very fast step for the operating system. The real file is always either the complete old version or the complete new version, never something in between.
Add Save to task/file.go:
// Save writes all tasks to the JSON file at path.
// It writes to a temporary file first, so a crash never leaves a half-written file.
func (s *Store) Save(path string) error {
data, err := s.ToJSON()
if err != nil {
return fmt.Errorf("encode tasks: %w", err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create folder: %w", err)
}
tmp, err := os.CreateTemp(dir, "tasks-*.tmp")
if err != nil {
return fmt.Errorf("create temporary file: %w", err)
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary file: %w", err)
}
if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("replace task file: %w", err)
}
return nil
}
Step by step:
filepath.Dir(path)returns the folder part of the path. For/home/ana/.tasker/tasks.json, it is/home/ana/.tasker.os.MkdirAllcreates the folder and any missing parent folders. If the folder already exists, it does nothing.os.CreateTempcreates a new file with a random name in the same folder, for exampletasks-123456.tmp. The*in the pattern is replaced by the random part. The temporary file must be in the same folder as the real file, because a rename only works reliably within one disk.tmp.Write(data)writes the JSON. It returns the number of bytes written, which you do not need here, so it goes to_.tmp.Close()finishes the file. Closing can fail too, for example when the disk is full, so you check the error.os.Renamemoves the temporary file to the real path and replaces the old file.
0o755 is a file permission in octal notation, a number system used for Unix permissions. It means: the owner can read, write, and open the folder, and other users can only read and open it. On Windows, Go mostly ignores these numbers.
Step 4: defer cleans up
Look at this line in Save:
defer os.Remove(tmp.Name())
defer schedules a function call to run when the surrounding function returns, no matter which return statement it uses. It is perfect for cleanup:
- If
Writefails,Savereturns early, and the deferredRemovedeletes the broken temporary file. - If everything succeeds, the temporary file has already been renamed.
Removefinds nothing to delete, and its error is ignored.
Without defer, you would need to call os.Remove before every return, and it is easy to forget one. Write the defer right after you create a resource, so the creation and the cleanup sit next to each other in the code.
Several
deferstatements run in reverse order: the last one you defer runs first. You will see this when you work with database connections and HTTP servers in Part 2.
Step 5: Choose where to store the file
Create path.go in the main package:
package main
import (
"fmt"
"os"
"path/filepath"
)
// dataPath returns the path of the task file.
// The TASKER_FILE environment variable can change it.
func dataPath() (string, error) {
if path := os.Getenv("TASKER_FILE"); path != "" {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("find home folder: %w", err)
}
return filepath.Join(home, ".tasker", "tasks.json"), nil
}
os.UserHomeDirreturns the user's home folder, for example/home/anaon Linux,/Users/anaon macOS, orC:\Users\anaon Windows.filepath.Joinjoins path parts with the correct separator for the operating system:/on Linux and macOS,\on Windows. Never join paths with+ "/" +.os.Getenv("TASKER_FILE")reads an environment variable, a setting that the user or the system passes to a program. If it is empty, Tasker uses the default path. This makes testing easy: you can point Tasker at a test file without touching your real tasks. Many command-line tools work this way.
Step 6: Load and save in the shell
Open shell.go. Change the start of runShell:
func runShell() {
path, err := dataPath()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
store := task.NewStore()
if err := store.Load(path); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("Loaded %d tasks from %s\n", store.Len(), path)
scanner := bufio.NewScanner(os.Stdin)
If loading fails, the shell stops. This is on purpose. If Tasker started with an empty store and then saved, it would overwrite the user's file and lose all the tasks in it.
Now save after every change. In the add case, add the save before the success message:
if err := store.Save(path); err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("Added:", t)
Do the same in the done case, before fmt.Println("Completed:", t).
Step 7: Try it
Use a test file, so your experiments stay separate from your real tasks:
go build
export TASKER_FILE=./data/tasks.json
./tasker shell
In Windows PowerShell, use $env:TASKER_FILE = ".\data\tasks.json" instead of export.
Loaded 0 tasks from ./data/tasks.json
> add Buy milk #shop
Added: 1. [ ] Buy milk #shop
> add Read book
Added: 2. [ ] Read book
> done 2
Completed: 2. [x] Read book
> quit
Bye!
Open data/tasks.json in your editor. It contains the JSON from Lesson 3.2. Now start the shell again:
Loaded 2 tasks from ./data/tasks.json
> list
1. [ ] Buy milk #shop
2. [x] Read book
> add Third
Added: 3. [ ] Third
Your tasks survived, and the new task gets ID 3.
Now break the file on purpose. Open data/tasks.json and change the first title to "". Start the shell:
error: load ./data/tasks.json: task 1: title is empty
The message shows every layer: which file, which task, and what is wrong. Fix the title and try again.
Why it works: errors carry their own story
Each function adds one short piece of context and passes the error up with %w. No function needs to know the whole story. The final message reads like a sentence, and code at the top can still use errors.Is to check the original cause.
Good context answers the question "What was I trying to do?" Use short phrases such as read task file or create folder, not "error while" or "failed to". Every message in the chain is an error, so those words add nothing.
Exercises
- Check the cause. In
runShell, useerrors.Is(err, task.ErrEmptyTitle)afterstore.Loadfails. If it matches, print an extra hint:hint: open the file and give every task a title. - Save on delete. If you added a
deletecommand in an earlier exercise, make it save the file after a successful delete. - A
pathcommand. Add a shell commandpaththat prints the path of the task file. - Count the layers. Use
errors.Unwrapin a loop to print each layer of the error from Step 7, until it returnsnil. How many layers are there? - Make the folder read-only. On macOS or Linux, run
chmod 555 dataand add a task in the shell. Read the error. Then runchmod 755 datato fix it. Which line ofSavefailed?
Checkpoint
You are ready for the next lesson if you can:
- Read and write files with
os.ReadFile,os.CreateTemp, andos.Rename - Handle a missing file with
errors.Is(err, os.ErrNotExist) - Explain the difference between
%vand%winfmt.Errorf - Explain when a deferred function call runs
- Explain why
Savewrites to a temporary file first - Build paths with
filepath.Joinand read environment variables withos.Getenv
