1. Why sort before output
When you write a CLI, it may feel like “row order is a minor detail”. That is, until someone starts parsing your output with a script, comparing results in tests, or simply trying to find “task No. 7” with their eyes. If the order keeps jumping around, it feels like the program has a mind of its own.
Sorting before output solves two practical problems:
- it makes the result deterministic: the same input data produces the same output;
- it makes the output usable: tasks are easier to search, compare, and group.
And this is especially important: you should sort the data, not the table/JSON strings—because formatting should be the last step, not the place where you “also do business logic on the side”.
To reinforce the idea, here is our pipeline (very simplified):
flowchart LR
A[Data from storage] --> B[List preparation]
B --> C[Sorting]
C --> D[Render table/json]
D --> E[stdout]
Sorting sits right before rendering: we have already decided what to show, and now we decide in what order.
What exactly are we sorting?
Before sorting, it helps to pause for a second and agree on this: we are not sorting the “output”, we are sorting a slice of structs. This matters because the struct is a single source of truth for all formats (both table and JSON). In previous lectures we relied on the idea: one []Task → two renders.
The minimal task model for our learning CLI (let’s call the app tasker) looks like this:
package main
import "time"
type Task struct {
ID int
Title string
Done bool
CreatedAt time.Time
}
If your current version of the app does not have CreatedAt yet, that’s fine: you can sort only by ID. But a time field is handy to demonstrate a typical “real-world” sorting criterion (and you already know time.Time from the time block).
2. sort.Slice: the basic way to sort
When you need a quick and straightforward start, sort.Slice is a great choice. It sorts in-place, meaning it changes the order of elements in the original slice. That is convenient, but sometimes surprising: you thought you “just printed”, but you actually mutated the data too.
Here is the most straightforward “ascending by ID” sort:
package main
import "sort"
func sortByID(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].ID < tasks[j].ID
})
}
There are two important points here:
- the comparator is a function that answers: “should element i go before element j?” It returns true if yes;
- if you sort before printing, then sorting by ID is a good default: simple, predictable, and easy to explain to a user.
You can sanity-check it with a tiny example:
package main
import (
"fmt"
"sort"
)
func main() {
tasks := []Task{{ID: 10, Title: "B"}, {ID: 2, Title: "A"}}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
fmt.Println(tasks[0].ID, tasks[1].ID) // 2 10
}
3. How to choose a sorting criterion
The word “criterion” may sound like something from an exam, but in programming it is simply a rule: which field (or combination of fields) we use to compare elements.
Partial order: why Done alone is not enough
Imagine you want to print unfinished tasks first, then finished tasks. It looks like this should be enough:
sort.Slice(tasks, func(i, j int) bool {
return !tasks[i].Done && tasks[j].Done
})
And this is where the classic problem starts. If both tasks have Done=false, the comparator returns false. If both have Done=true, it also returns false. In other words, for most pairs the comparator says “I don’t know”. As a result, the order within a group can become… “whatever happens”.
Sometimes that “whatever happens” looks stable on your machine, but in reality you left a hole in the contract: the order is not fully defined.
Total order: a secondary key
The solution is simple: if elements are equal by the primary field, compare by a secondary one. That is the “secondary key”.
Sort “UNDONE first, within groups by ID”:
package main
import "sort"
func sortByDoneThenID(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
a, b := tasks[i], tasks[j]
if a.Done != b.Done {
return !a.Done && b.Done // false < true, meaning UNDONE comes before DONE
}
return a.ID < b.ID
})
}
Now this is a real contract: for any two tasks we can unambiguously say which one comes first.
Cheat sheet: how to compare fields
Sometimes it is hard for a beginner to remember how to compare time.Time, or whether strings can be compared via <. Here is a short cheat sheet:
| Field | Type | How to compare in Go | Comment |
|---|---|---|---|
|
|
|
The simplest criterion |
|
|
|
Lexicographic, case-sensitive |
|
|
|
Better to be explicit for readability |
|
|
|
Do not compare via < |
Example: sort by creation time:
package main
import "sort"
func sortByCreatedAt(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
})
}
4. sort.Slice and sort.SliceStable
Sort stability means: if two elements are “equal” by the sort key, their relative order is preserved as it was before sorting. This is useful when you sort “in multiple steps” or when the original order also carries meaning.
The sort package provides sort.SliceStable for this. Its usage is similar to sort.Slice, it just guarantees stability:
package main
import "sort"
func sortByDoneStable(tasks []Task) {
sort.SliceStable(tasks, func(i, j int) bool {
return !tasks[i].Done && tasks[j].Done
})
}
A subtle point: even a stable sort will not save you if the original order is “random” (for example, you collected tasks from a map without sorting keys). So for a CLI contract it is usually simpler and more reliable to define a total order (primary + secondary key) than to rely on stability and “whatever happened before”.
5. The slices package and slices.SortFunc — Go 1.21+
Go’s standard library gained the slices package, which provides common operations on slices, including sorting. It was added as a standard solution for typical slice tasks, and it also includes more convenient sorting functions.
For simple cases (when elements are orderable) there is slices.Sort, and for custom comparisons there is slices.SortFunc. In materials about slices, you often see slices.Sort and slices.Clone shown as “regular tools” that now read simpler than homegrown reinventions.
Why slices.SortFunc reads simpler
sort.Slice works with indices i, j. But slices.SortFunc lets you compare values directly (via a comparator function). This often reads more naturally: we compare a and b, not “elements at positions i and j”.
A typical pattern looks like this (the comparator returns an int: negative/0/positive):
package main
import (
"cmp"
"slices"
)
func sortByID2(tasks []Task) {
slices.SortFunc(tasks, func(a, b Task) int {
return cmp.Compare(a.ID, b.ID)
})
}
The cmp package here is the standard way to compare orderable values (numbers, strings) via cmp.Compare, so you don’t have to handwrite “if less—return -1…”. It makes the code stylistically consistent.
A complex criterion with a secondary key
Let’s implement the same contract as before: UNDONE first, then DONE; within groups, sort by ID.
package main
import (
"cmp"
"slices"
)
func sortByDoneThenID2(tasks []Task) {
slices.SortFunc(tasks, func(a, b Task) int {
if a.Done != b.Done {
if !a.Done {
return -1
}
return 1
}
return cmp.Compare(a.ID, b.ID)
})
}
Yes, this is a few more lines than sort.Slice, but the logic becomes very “tabular”: it’s like we are writing comparison rules.
In-place sorting and making a slice copy
Both sort.Slice and slices.SortFunc sort in-place. This is normal and expected semantics: you reorder elements, you don’t create a “new universe of tasks”.
By the way, the slices package provides tools to make a copy before sorting, if you need to preserve the original order. A typical pattern: s2 := slices.Clone(s) → sort s2, while the original s stays unchanged.
6. Integrating sorting into the CLI pipeline
Now let’s assemble this into a cohesive piece of our app. Assume we already have a list command that loads tasks from storage and then calls a renderer (table or JSON—that part was done in previous lectures).
We will add one function: prepareForOutput, which performs sorting. We intentionally do not touch filters here: that is a separate topic, covered in the next lecture.
package main
func prepareForOutput(tasks []Task) []Task {
// If we don't want to mutate the original slice, make a copy.
out := make([]Task, len(tasks))
copy(out, tasks)
sortByDoneThenID(out) // or sortByDoneThenID2(out)
return out
}
Now in the list command handler the logic becomes linear and calm:
package main
import "os"
func runList() error {
tasks, err := loadTasks() // assume it already exists
if err != nil {
return err
}
tasks = prepareForOutput(tasks)
return render(os.Stdout, "table", tasks) // the format has already been normalized earlier
}
The philosophy matters here: render no longer has to think about ordering. It receives a prepared list and just prints it honestly. This noticeably simplifies maintenance: when you later add a second output format or tests (and you will), sorting remains a single line in a single place, rather than being scattered throughout the project.
7. Common mistakes when sorting before output
Mistake No. 1: sorting only by the primary key without a secondary one, causing the order to “jitter”.
This is the most insidious bug: you sort by Done, the output “seems” fine, and then suddenly in one run tasks within a group appear one way, and in another run they appear differently. The reason is that you did not define a total order. Fix it by adding a secondary key (for example, ID) so the comparison is defined for any two elements.
Mistake No. 2: sorting inside the renderer (table/JSON) rather than before it.
At first it seems convenient: “I’m iterating there anyway, I’ll just sort there too.” In practice, this mixes two layers: data preparation and formatting. The result: the table gets sorted, JSON is forgotten, tests fail, and users get annoyed. It is better to keep sorting as part of the data preparation step and call the renderer on a ready []Task.
Mistake No. 3: the original slice changes unexpectedly (in-place sort), and later logic starts behaving strangely.
Both sorts (sort.Slice and slices.SortFunc) change the order of elements in the passed slice. If somewhere downstream you relied on the original order (or reuse tasks), you get a “side effect”. If you need to preserve order, make a copy via copy or slices.Clone, and sort the copy.
Mistake No. 4: the comparator is written in a way that violates “common-sense” comparison.
Sometimes the comparator becomes contradictory: for three elements A, B, C it may claim A < B, B < C, but also C < A. The sort is not required to “fail nicely” in such a case—it simply becomes unpredictable. If the criterion is complex, compare step by step: primary field, then secondary, and only then (if needed) a third one.
Mistake No. 5: expecting “JSON to stabilize itself”.
JSON output is stable only to the extent that your []Task is stable. If the order in the slice drifts, the array in JSON will drift as well. That is why sorting is a required step for both formats: for table and for json. The slices package and its sorting helpers are exactly what helps keep this step short and readable.
GO TO FULL VERSION