A slice of structs can be sorted by an ID, timestamp, or other field even though the struct itself has no built-in ordering. When you need repeated lookups in that sorted data, slices.BinarySearchFunc lets the search use the same ordering without building a separate index.

The comparator is the part that deserves attention. It doesn’t compare two slice elements. It compares one element from the slice with the search target, and its ordering must agree with the way the slice is sorted.

How slices.BinarySearchFunc works

The function accepts a sorted slice, a target, and a comparison function:

func BinarySearchFunc[S ~[]E, E, T any](x S, target T, cmp func(E, T) int) (int, bool)

The comparator returns a negative value when the slice element comes before the target, zero when it matches, and a positive value when it comes after the target. The target type T can differ from the slice element type E, which is especially useful for structs.

Suppose users are kept in ascending order by numeric ID:

package main

import (
    "cmp"
    "fmt"
    "slices"
)

type User struct {
    ID   int
    Name string
}

func main() {
    users := []User{
        {ID: 10, Name: "Ana"},
        {ID: 20, Name: "Bima"},
        {ID: 40, Name: "Dewi"},
    }

    index, found := slices.BinarySearchFunc(users, 20, func(user User, id int) int {
        return cmp.Compare(user.ID, id)
    })

    fmt.Println(index, found)
    if found {
        fmt.Println(users[index].Name)
    }
}

The output is:

1 true
Bima

There is no need to construct a placeholder User{ID: 20} just to search. The target can be an int because BinarySearchFunc allows the element and target to have different types.

A miss returns the insertion point

Like slices.BinarySearch, the function returns useful information when no match exists. Searching the same ordered data for ID 30 gives index 2 with found == false: ID 30 belongs after 20 and before 40.

index, found := slices.BinarySearchFunc(users, 30, func(user User, id int) int {
    return cmp.Compare(user.ID, id)
})

fmt.Println(index, found) // 2 false

That makes the result convenient for code that maintains a sorted slice. You can search first, then insert only when the key is absent:

func insertUser(users []User, user User) []User {
    index, found := slices.BinarySearchFunc(users, user.ID, func(existing User, id int) int {
        return cmp.Compare(existing.ID, id)
    })
    if found {
        return users
    }

    return slices.Insert(users, index, user)
}

This helper treats the ID as unique. If duplicate IDs are valid in your domain, that policy needs to be explicit instead of hidden inside the search code.

The returned insertion point can be 0 for a target smaller than every element, or len(users) for one larger than every element. Don’t index users[index] after a miss without checking that the index is in range.

Keep the search comparator aligned with the sort order

Binary search only works because it can rule out part of the slice after each comparison. That reasoning breaks when the slice and comparator disagree about ordering.

For example, this slice is sorted by Name:

users := []User{
    {ID: 40, Name: "Ana"},
    {ID: 10, Name: "Bima"},
    {ID: 20, Name: "Dewi"},
}

Searching it by ID with cmp.Compare(user.ID, id) does not satisfy the BinarySearchFunc contract. The IDs appear as 40, 10, 20, so they aren’t increasing according to that comparator. A result from that search shouldn’t be trusted.

A practical way to avoid this mismatch is to define the ordering once when the element and target types permit it, or at least keep the sort and search comparators next to each other:

func compareUserID(a, b User) int {
    return cmp.Compare(a.ID, b.ID)
}

func compareUserToID(user User, id int) int {
    return cmp.Compare(user.ID, id)
}

slices.SortFunc(users, compareUserID)
index, found := slices.BinarySearchFunc(users, 20, compareUserToID)

The functions have different signatures, but both express the same ordering: ascending numeric ID.

Duplicate keys return the earliest matching position

If several adjacent records compare equal to the target, BinarySearchFunc returns the earliest matching position. Consider a slice with two users sharing ID 20:

users := []User{
    {ID: 10, Name: "Ana"},
    {ID: 20, Name: "Bima"},
    {ID: 20, Name: "Bimo"},
    {ID: 40, Name: "Dewi"},
}

index, found := slices.BinarySearchFunc(users, 20, func(user User, id int) int {
    return cmp.Compare(user.ID, id)
})

fmt.Println(index, found) // 1 true

This is useful when you need the beginning of a run of equal keys. It does not, by itself, tell you where that run ends. If you need every record with ID 20, start at the returned index and scan forward while the IDs remain equal, or use a second boundary-search strategy when the runs can be large.

Also consider what equality means for your application. A comparator that only examines ID intentionally treats two users with the same ID as equal for search purposes even if their names differ. That’s correct for an ID lookup, but it would be wrong if your actual key were (ID, Name).

Searching strings with normalization needs one consistent rule

Custom comparison is also useful when raw string ordering isn’t the ordering your application wants. A case-insensitive lookup is a common example, but both sorting and searching must use the same normalization.

func compareName(a, b User) int {
    return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
}

func compareUserToName(user User, target string) int {
    return strings.Compare(strings.ToLower(user.Name), strings.ToLower(target))
}

If the slice was sorted case-sensitively and then searched case-insensitively, the comparator no longer describes the slice order. The problem isn’t specific to lowercase conversion; the same caveat applies to trimmed values, parsed versions, compound keys, and other domain-specific ordering rules.

When normalization is expensive and searches are frequent, repeatedly normalizing every comparison may also be unnecessary work. Storing a normalized key alongside the display value, or maintaining a separate lookup structure, can be a better design depending on the workload.

Empty slices need no special case

BinarySearchFunc handles an empty or nil slice normally. There is one possible insertion point, index zero, and no value can be found.

var users []User

index, found := slices.BinarySearchFunc(users, 7, func(user User, id int) int {
    return cmp.Compare(user.ID, id)
})

fmt.Println(index, found) // 0 false

That means a helper that searches and then inserts can work with a nil slice without adding an early len(users) == 0 branch.

Use it when sorted order is already part of the design

slices.BinarySearchFunc fits best when the slice is already sorted by the field you need to search, or when sorting once supports many later lookups. For a single lookup in unsorted data, a straightforward scan avoids the cost and mutation involved in sorting first.

For larger collections with frequent inserts, deletes, and key lookups, maintaining sorted slices may become more work than the searches save. A map can provide a simpler key-based index when ordering isn’t needed. If you do need sorted traversal as well, the right structure depends on which operations dominate.

When a sorted slice is the right representation, keep one rule in view: the comparator used by slices.BinarySearchFunc must describe the order the slice actually has. Once that invariant is clear, searching structs by a field is compact, type-safe, and gives you both matches and insertion points without extra bookkeeping.