15.3 Collection Functions: where, first, last, after, sort, shuffle, group
Right, let’s get our hands dirty with Hugo’s collection functions. These are your power tools for wrangling your content into submission. Think of them as the filters and sorters for your data—the logic that turns a messy pile of content into the exact list you need for your nav, your featured posts, or your “related articles” section.
The most important thing to remember is that these functions don’t change your original collection. They return a new, filtered, or sorted version of it. This is a core Hugo principle: data transformation should be side-effect free. It keeps things predictable, which is something we can all get behind.
The where Function: Your Workhorse
This is the one you’ll use 80% of the time. It filters a collection of pages (or any slice of maps, really) based on the value of a field. The syntax is straightforward: where COLLECTION "KEY" "OPERATOR" "VALUE".
// Get all posts in the 'tutorials' section
{{ $tutorials := where .Site.Pages "Section" "==" "tutorials" }}
// Get all posts with a 'draft' front matter flag set to false
{{ $published := where .Site.Pages "Params.draft" "!=" true }}
// A common gotcha: checking for the *existence* of a param.
// Use 'where' without an operator or value.
{{ $hasImages := where .Site.Pages "Params.hero_image" }}
Why the quotes around the operator? Because Hugo templates aren’t Go code. That "==" is a string that the where function parses internally. It supports "==", "!=", "in", "not in", and for numbers, ">=", "<=", etc.
Pitfall Alert: The where function is case-sensitive. Always. where .Pages "Section" "==" "blog" will not find pages in a section named “Blog”. This has tripped me up more times than I care to admit. If you need case-insensitive filtering, you might need to create a custom “lowercase” field via a scratch or use findRE, but that’s a story for another day.
first, last, and after: Slicing and Dicing
These are your tools for grabbing chunks of a collection. They do exactly what they say on the tin.
first and last take an integer N and a collection and return the first or last N elements. Crucially, first will often be paired with a sort to get the most recent posts.
// Get the 3 most recent published tutorials
{{ $allTutorials := where .Site.Pages "Section" "tutorials" }}
{{ $recentTutorials := $allTutorials | first 3 }}
// Wait, that's wrong! We didn't sort them. This just gets the first 3
// it found, which is essentially random. Let's do it properly.
{{ $allTutorials := where .Site.Pages "Section" "tutorials" }}
{{ $sortedTutorials := sort $allTutorials "Date" "desc" }}
{{ $recentTutorials := $sortedTutorials | first 3 }}
after is a bit less intuitive but incredibly useful. It returns all items after the Nth index. Want to list “Other Posts” after featuring the first one? after is your friend.
{{ $featuredPost := first 1 $sortedTutorials }}
{{ $otherPosts := after 1 $sortedTutorials }}
sort and shuffle: Putting Things in Order (Or Not)
sort takes a collection and a field name and sorts by that field. You can add "asc" or "desc" as a third argument. Simple, right? Well, mostly.
// Sort by publish date, newest first
{{ $sorted := sort $allPages "Date" "desc" }}
// Sort by a custom front matter value, like 'rating'
{{ $sortedByRating := sort $allPages "Params.rating" "desc" }}
Here’s the designer’s questionable choice: sort only works on fields that are simple types (strings, numbers, dates). If you try to sort by something like "Title" and one of your titles is an integer (2022), you’ll get lexicographical sorting (“1”, “10”, “2”) instead of numerical sorting (1, 2, 10). It’s a known quirk. For true numerical sorting, you often have to ensure your front matter value is defined as a number (rating: 5) and not a string (rating: "5").
shuffle is the chaos engine. It randomizes the order of a collection. Perfect for “You might also like…” sections or featured content boxes where you don’t want to play favorites.
{{ $randomPosts := $allPages | shuffle | first 4 }}
A word of caution: Hugo’s build is deterministic. The same site built twice will have the same “random” shuffle unless you change the source content. It’s random-ish, but predictable.
group: For When You Need Buckets
This one is more advanced but unlocks powerful patterns. group takes a collection and a key and returns a map of groups, where each key is a value and the value is a slice of pages that share it.
The classic example? Grouping posts by year or month for archive pages.
{{ $allPosts := where .Site.Pages "Section" "blog" }}
{{ $postsByYear := $allPosts | group "Date.Year" }}
{{ range $postsByYear }}
<h2>{{ .Key }}</h2> <!-- The year, e.g., 2023 -->
<ul>
{{ range .Pages }}
<li><a href="{{ .Permalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ end }}
Notice we used "Date.Year". This is Hugo’s magic method invocation. .Date is a time.Time object, and we’re calling its .Year() method. You can do this with any field that has methods. It’s a fantastic feature that they don’t advertise nearly enough.
The biggest pitfall with group is that the key is always a string. Even if you group by "Date.Year", which returns an integer, the .Key in your range loop will be the string representation of that integer ("2023"). If you need to treat it as a number again for comparison logic, you’ll have to convert it back using int. It’s a minor annoyance, but it’ll save you an hour of debugging when you inevitably forget.