Modeling Data
Find and Group Tasks with Maps
Goal
By the end of this lesson, Tasker stores tasks in a map, so it finds any task by its ID in one step. Tasks can have tags such as #home and #shop. You can list tasks by tag and count how many tasks each tag has.
What you will learn
- How to create a map, add values, read values, and delete values
- The "comma ok" form, which tells you if a key exists
- Why the order of a map is random, and how to sort its keys
- Why you cannot change a struct field directly inside a map
- How to use a map to count things
- Why you must create a map with
makebefore you write to it
Why this matters
In Lesson 2.2, IndexOf checked every task, one by one, to find an ID. With 10 tasks, that is fast. With 100,000 tasks, it is slow. A map connects a key to a value and finds any key in one step, no matter how many items it holds. You will use maps for lookups, counting, grouping, caches, and configuration.
Step 1: Learn maps in the playground
Replace play/main.go with this:
package main
import "fmt"
func main() {
ages := map[string]int{
"Ana": 31,
"Ben": 25,
}
ages["Chen"] = 40
fmt.Println(ages["Ana"], len(ages))
fmt.Println(ages["Dana"])
age, ok := ages["Dana"]
fmt.Println(age, ok)
delete(ages, "Ben")
fmt.Println(ages)
for name, age := range ages {
fmt.Println(name, age)
}
}
Run it with go run ./play. Output:
31 3
0
0 false
map[Ana:31 Chen:40]
Ana 31
Chen 40
Line by line:
map[string]int{...}creates a map whose keys are strings and whose values are integers.ages["Chen"] = 40adds a key, or changes its value if the key already exists.ages["Ana"]reads a value.len(ages)is the number of keys.ages["Dana"]reads a key that does not exist. It does not crash. It returns the zero value,0.age, ok := ages["Dana"]is the comma ok form.okistrueif the key exists andfalseif it does not. Use it whenever "missing" and "zero" mean different things.delete(ages, "Ben")removes a key. If the key does not exist, nothing happens.for name, age := range agesloops over every key and value.
Step 2: Map order is random
Run the playground several times. The order of the last two lines can change between runs:
Chen 40
Ana 31
Go does not keep map keys in any order, and it changes the loop order on purpose. If code depends on map order, it fails sooner or later, so Go makes the problem visible early.
When you need a fixed order, sort the keys first:
import (
"maps"
"slices"
)
for _, name := range slices.Sorted(maps.Keys(ages)) {
fmt.Println(name, ages[name])
}
maps.Keys gives all the keys, and slices.Sorted returns them as a sorted slice. Now the output is the same every time.
Version note:
slices.Sortedandmaps.Keysneed Go 1.23 or newer. Check thegoline in yourgo.modfile. If it says an older version, change it to your installed version, for examplego 1.24.
Step 3: Two map rules that surprise beginners
Rule 1: You cannot change a struct field inside a map directly.
tasks := map[int]task.Task{}
tasks[1].Done = true
Go refuses to compile this:
cannot assign to struct field tasks[1].Done in map
A map gives you a copy of the value, and changing a copy does nothing useful. So Go stops you. Instead, read the value, change it, and store it back:
t := tasks[1]
t.Done = true
tasks[1] = t
Rule 2: A nil map can be read, but not written.
var counts map[string]int
counts["home"] = 1
This compiles, but the program crashes:
panic: assignment to entry in nil map
The zero value of a map is nil. A nil map has no storage behind it. Before you write to a map, create it with make or with a literal:
counts := make(map[string]int)
counts := map[string]int{}
Both lines create an empty map that is ready to use.
Step 4: Add tags to Task
Open task/task.go. Add a Tags field to the struct:
type Task struct {
ID int
Title string
Done bool
Tags []string
CreatedAt time.Time
}
Update New so it accepts tags:
// New creates an open task with the given ID, title, and tags.
func New(id int, title string, tags []string) Task {
return Task{
ID: id,
Title: title,
Tags: tags,
CreatedAt: time.Now(),
}
}
Update the end of Format so it shows the tags:
line := fmt.Sprintf("%d. %s %s", t.ID, status, t.Title)
for _, tag := range t.Tags {
line += " #" + tag
}
return line
In main.go, the two calls to task.New now need a third argument. Pass nil, which means "no tags":
t := task.New(1, title, nil)
Step 5: Replace the list helpers
Replace task/list.go with this:
package task
import (
"maps"
"slices"
"strings"
)
// SplitTags separates title words from tags. A tag is a word that starts with "#".
func SplitTags(words []string) (string, []string) {
var titleWords []string
var tags []string
for _, w := range words {
if strings.HasPrefix(w, "#") && len(w) > 1 {
tags = append(tags, strings.ToLower(w[1:]))
} else {
titleWords = append(titleWords, w)
}
}
return strings.Join(titleWords, " "), tags
}
// SortedIDs returns the IDs of all tasks, from lowest to highest.
func SortedIDs(tasks map[int]Task) []int {
return slices.Sorted(maps.Keys(tasks))
}
// CountByTag returns how many tasks have each tag.
func CountByTag(tasks map[int]Task) map[string]int {
counts := make(map[string]int)
for _, t := range tasks {
for _, tag := range t.Tags {
counts[tag]++
}
}
return counts
}
NextID and IndexOf are gone. The map replaces IndexOf, and a simple counter replaces NextID.
Look at counts[tag]++ in CountByTag. If tag is new, counts[tag] returns the zero value 0, and ++ makes it 1. If the tag already exists, its count goes up by one. The zero value makes counting with a map very short.
w[1:] works on strings the same way it works on slices. It returns the text without its first character, so #home becomes home.
Step 6: Update the shell
Replace shell.go with this:
package main
import (
"bufio"
"fmt"
"maps"
"os"
"slices"
"strconv"
"strings"
"tasker/task"
)
func runShell() {
tasks := make(map[int]task.Task)
nextID := 1
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("Tasker shell. Commands: add <title> [#tag...], list [#tag], done <id>, tags, quit")
for {
fmt.Print("> ")
if !scanner.Scan() {
break
}
words := strings.Fields(scanner.Text())
if len(words) == 0 {
continue
}
switch words[0] {
case "add":
title, tags := task.SplitTags(words[1:])
if title == "" {
fmt.Println("error: add needs a title")
continue
}
t := task.New(nextID, title, tags)
tasks[t.ID] = t
nextID++
fmt.Println("Added:", task.Format(t))
case "list":
filter := ""
if len(words) == 2 {
filter = strings.TrimPrefix(strings.ToLower(words[1]), "#")
}
shown := 0
for _, id := range task.SortedIDs(tasks) {
t := tasks[id]
if filter != "" && !slices.Contains(t.Tags, filter) {
continue
}
fmt.Println(task.Format(t))
shown++
}
if shown == 0 {
fmt.Println("No tasks found.")
}
case "done":
if len(words) != 2 {
fmt.Println("error: done needs exactly one task ID")
continue
}
id, err := strconv.Atoi(words[1])
if err != nil {
fmt.Printf("error: %q is not a valid task ID\n", words[1])
continue
}
t, ok := tasks[id]
if !ok {
fmt.Printf("error: no task with ID %d\n", id)
continue
}
t.Done = true
tasks[id] = t
fmt.Println("Completed:", task.Format(t))
case "tags":
counts := task.CountByTag(tasks)
if len(counts) == 0 {
fmt.Println("No tags yet.")
}
for _, tag := range slices.Sorted(maps.Keys(counts)) {
fmt.Printf("#%s: %d\n", tag, counts[tag])
}
case "quit":
fmt.Println("Bye!")
return
default:
fmt.Printf("error: unknown command %q\n", words[0])
}
}
}
What changed:
tasks := make(map[int]task.Task)stores tasks by ID.nextIDcounts up for each new task.addusesSplitTags, then stores the task withtasks[t.ID] = t.listloops over sorted IDs, so tasks always appear in order. With a tag, such aslist #home, it skips tasks without that tag.slices.Containschecks if a slice holds a value.doneuses comma ok to check that the task exists, then reads, changes, and stores the task back. This is Rule 1 from Step 3.tagsis a new command that prints the count for each tag, in sorted order.
Step 7: Try it
go build
./tasker shell
A sample session:
> add Buy milk #shop #home
Added: 1. [ ] Buy milk #shop #home
> add Fix bike #home
Added: 2. [ ] Fix bike #home
> add Read book
Added: 3. [ ] Read book
> add #shop
error: add needs a title
> done 2
Completed: 2. [x] Fix bike #home
> list #home
1. [ ] Buy milk #shop #home
2. [x] Fix bike #home
> list #work
No tasks found.
> tags
#home: 2
#shop: 1
> quit
Bye!
Why it works: choose the structure that fits the question
- A slice answers "What is the order?" It keeps items in sequence.
- A map answers "Where is the item with this key?" It finds any key in one step.
Tasker now stores tasks in a map, because its most common question is "Which task has ID 3?" When it needs order, it sorts the keys. Real programs often combine both, for example a map for fast lookups and a sorted slice for display.
Exercises
- Delete a task. Add a
delete <id>command. Use comma ok to report an error if the ID does not exist, then usedelete. - Remove duplicate tags.
add Test #home #homestoreshometwice. ChangeSplitTagsto skip a tag it has already seen. Hint: use amap[string]boolto remember which tags you have added. - Group by tag. Write
GroupByTag(tasks map[int]Task) map[string][]Task, which returns the tasks for each tag. Add agroupscommand that prints each tag, followed by its tasks. - Count done and open. Write a function that returns a
map[bool]int, with the number of done tasks undertrueand open tasks underfalse. - Break it on purpose. In
CountByTag, changemake(map[string]int)tovar counts map[string]int. Runtagswith some tagged tasks. Read the panic message, then fix the code.
Checkpoint
You are ready for the next lesson if you can:
- Create a map, add and read values, and delete keys
- Use comma ok to check if a key exists
- Print a map's contents in a fixed order
- Change a struct stored in a map, and explain why
tasks[1].Done = truedoes not compile - Explain why writing to a
nilmap crashes, and how to avoid it
