JSON on Disk, Safely
Right now til has amnesia - every run starts from nothing. This phase gives it memory: a JSON file in your home directory that add appends to and a new list command reads back. The interesting part isn't reading and writing JSON - Go makes that almost free - it's doing it in a way that can't eat your notes. A note-taking tool that corrupts its own store the one time your laptop dies mid-save is worse than no tool at all, so we'll write the save path the way real tools do.
Where state lives
CLI tools keep their state in a dotfolder in your home directory - that's the convention behind ~/.gitconfig, ~/.ssh, ~/.docker. We'll follow it: notes go in ~/.til/notes.json. The reader-visible win: it's one JSON file you can open, read, and back up with anything.
Go hands you the home directory portably:
// storePath returns the notes file location: ~/.til/notes.json.
os.UserHomeDir returns C:\Users\you on Windows and /home/you on Linux, and filepath.Join uses the right slashes for the platform - two functions, and the path question is closed on every OS. Note that the path comes back as a return value instead of living in a global: every function that touches the store will take the path as a parameter. That looks like a small style choice today; in phase 5 it's the whole reason the storage code is testable.
Teaching the struct to speak JSON
encoding/json converts between Go structs and JSON text. By default it uses the Go field names - Text, Created - but JSON convention is lowercase. Struct tags fix the mapping:
📝 Terminology: the backtick string after a field is a struct tag - metadata that packages like encoding/json read to decide how to handle the field. json:"text" says "call this key text in the JSON." The extra omitempty on Tags says "if the slice is empty, leave the key out entirely" - an untagged note stores as two lines shorter, and it's why parseTags returning nil for no tags was fine.
⚠️ Gotcha: struct tags only apply to exported fields (capitalized names). Rename a field to text and encoding/json skips it silently - no error, the data is simply absent from the file. If a field ever mysteriously stops persisting, check its capitalization first.
Loading: a missing file is not an error
// loadNotes reads the store. A missing file is a first run, not an error.
The first if is the line that makes the tool pleasant. On a brand-new machine, notes.json doesn't exist - and that's not a failure, it's day one. errors.Is(err, os.ErrNotExist) asks "is this error, or anything it wraps, a file-not-found?" and if so we return an empty list and move on. Every other error - permissions, a disk problem, JSON someone hand-edited into invalidity - is real and gets reported, with the file path wrapped in so the user knows which file to look at.
Saving: the atomic write
Here's the scenario that separates careful tools from careless ones. You have 200 notes. til opens notes.json, starts writing the new version, and halfway through - power cut, kill -9, out-of-battery laptop lid snap. The file now contains half a JSON array. Next run, loadNotes fails to parse it. All 200 notes are hostage to a text file that ends mid-sentence.
The fix is old, small, and standard: never write onto your only copy. Write the new version to a temporary file next to the real one, and only when it's fully on disk, rename it over the original. A rename within the same directory is a single atomic operation at the filesystem level - it either fully happens or doesn't happen at all. There is no moment where notes.json is half-written.
flowchart LR
A[notes in memory] --> B[write notes.json.tmp]
B --> C{write completed?}
C -- yes --> D[rename over notes.json]
C -- "crash mid-write" --> E[notes.json untouched]
// saveNotes writes the store atomically: temp file first, then rename.
Reading it through:
os.MkdirAllcreates~/.tilif it's missing (and does nothing if it exists) - without this, the very first save fails because the folder isn't there. The0o755is the Unix permission bits (owner can write, everyone can read); Windows largely ignores them.json.MarshalIndent(notes, "", " ")serializes the whole slice with two-space indentation. Plainjson.Marshalwould produce one long line; indented output means you can open your own store and read it, which you will, the first time you're curious.- Write
.tmp, thenos.Rename. The two-step described above. If the process dies duringWriteFile, the damage is a straynotes.json.tmp- your real store never changed.
💡 Key point: load tolerates absence, save tolerates interruption. Those two decisions are what "storing state safely" means, and they cost eleven lines total.
Wiring it into add and list
runAdd now loads, appends, and saves. The new note's ID is the last note's ID plus one - IDs only ever grow, so they stay stable even if you later add a delete command. And runList is new - minimal for now, a proper table comes next phase.
Here's the complete main.go for this phase:
package main
// parseTags turns "Go, CLI" into ["go", "cli"].
// storePath returns the notes file location: ~/.til/notes.json.
// loadNotes reads the store. A missing file is a first run, not an error.
// saveNotes writes the store atomically: temp file first, then rename.
One new thing hides in runList: the date format. Go doesn't use YYYY-MM-DD patterns - you format times by writing the reference moment, Mon Jan 2 15:04:05 MST 2006, in the layout you want. n.Created.Format("2006-01-02") means "year-month-day, like 2006-01-02." It confuses everybody the first time; the mnemonic is that the reference date's parts count 1 2 3 4 5 6 in US order (month 1, day 2, hour 3, minute 4, second 5, year 6).
Run it
$ go run . add -tags go "defer runs in LIFO order"
Added note #1
$ go run . add -tags go,cli "flag.NewFlagSet gives each subcommand its own flags"
Added note #2
$ go run . list
#1 [2026-07-06] defer runs in LIFO order
#2 [2026-07-06] flag.NewFlagSet gives each subcommand its own flags
What just happened: two add runs each loaded the store, appended, and saved atomically; list read the result back. The notes survived across three separate processes - til has memory now.
Go look at the memory. Open ~/.til/notes.json (on Windows, C:\Users\you\.til\notes.json):
What just happened: every struct tag did its job - lowercase keys, and time.Time serialized itself as an RFC 3339 timestamp (timezone included) that it can parse back losslessly. Your store is plain text you could edit, back up, or sync between machines.
What you have now
A CLI with durable, crash-safe state and stable note IDs. The list output is honest but flat - and there's no way yet to find anything, which for a memory tool is the entire point. Next phase: list grows flags and a real table, and search and tags join the roster.
Lock in the storage ideas before moving on:
[
{
"q": "Why does saveNotes write to notes.json.tmp and then rename, instead of writing notes.json directly?",
"choices": [
"It is faster than writing the file directly",
"So two til processes can safely write at the same time",
"A crash mid-write can only leave a broken .tmp file - the real notes.json is never half-written"
],
"answer": 2,
"explain": "The rename is atomic, so the store flips from old version to new version with no in-between state. (Two processes writing at once is a different problem - the rename trick does not solve that.)"
},
{
"q": "What does loadNotes do when notes.json does not exist?",
"choices": [
"Returns an error so the user knows something is wrong",
"Returns an empty list - a first run is not an error",
"Creates the file immediately with an empty array"
],
"answer": 1,
"explain": "errors.Is(err, os.ErrNotExist) catches file-not-found and treats it as day one. The file gets created by the first save instead."
},
{
"q": "What does the struct tag json:\"text\" on the Text field do?",
"choices": [
"Controls the key name used when the struct is marshaled to JSON",
"Renames the field everywhere in Go code",
"Validates that the field is non-empty before saving"
],
"answer": 0,
"explain": "Struct tags are metadata for packages like encoding/json. Without the tag, the JSON key would be the Go field name, Text."
}
]
Check your understanding 3 questions
1. Why does saveNotes write to notes.json.tmp and then rename, instead of writing notes.json directly?
2. What does loadNotes do when notes.json does not exist?
3. What does the struct tag json:"text" on the Text field do?