Custom Cache Indices in controller-runtime

February 10, 2026

Custom Cache Indices in controller-runtime
Custom Cache Indices in controller-runtime

The controller-runtime cache can list resources by namespace and name. Custom indices let you query by any field.

Estimated Reading Time : 7m

The problem

Your reconciler needs to find all Backups that reference a specific database. Without an index, you have to list every Backup and filter in memory:

var backups v1alpha1.BackupList
if err := r.List(ctx, &backups); err != nil {
    return ctrl.Result{}, err
}

var matching []v1alpha1.Backup
for _, b := range backups.Items {
    if b.Spec.Database == "orders" {
        matching = append(matching, b)
    }
}

This works but scales poorly. With thousands of resources, you’re loading the entire list from the cache and iterating through it on every reconcile.

Adding a custom index

Register an index with the manager’s field indexer before starting the controller:

func main() {
    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{})
    if err != nil {
        log.Fatal(err)
    }

    // Register the index
    if err := mgr.GetFieldIndexer().IndexField(
        context.Background(),
        &v1alpha1.Backup{},
        "spec.database",
        func(obj client.Object) []string {
            backup := obj.(*v1alpha1.Backup)
            return []string{backup.Spec.Database}
        },
    ); err != nil {
        log.Fatal(err)
    }

    // Set up controllers...
}

The arguments:

  • The resource type to index (&v1alpha1.Backup{})
  • The index name ("spec.database" — by convention, matches the field path)
  • An extractor function that returns the index values for a given object

Querying the index

Now you can filter directly:

var backups v1alpha1.BackupList
if err := r.List(ctx, &backups, client.MatchingFields{
    "spec.database": "orders",
}); err != nil {
    return ctrl.Result{}, err
}

The cache returns only matching resources — no iteration needed.

Use with secondary watches

Custom indices are essential for Watches with EnqueueRequestsFromMapFunc. When a Secret changes, you need to find all resources that reference it:

// Index: which Secret does each Backup use?
mgr.GetFieldIndexer().IndexField(
    context.Background(),
    &v1alpha1.Backup{},
    "spec.secretRef",
    func(obj client.Object) []string {
        backup := obj.(*v1alpha1.Backup)
        if backup.Spec.SecretRef == "" {
            return nil
        }
        return []string{backup.Spec.SecretRef}
    },
)

// Watch: when a Secret changes, reconcile Backups that reference it
ctrl.NewControllerManagedBy(mgr).
    For(&v1alpha1.Backup{}).
    Watches(
        &corev1.Secret{},
        handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request {
            var backups v1alpha1.BackupList
            if err := mgr.GetClient().List(ctx, &backups, client.MatchingFields{
                "spec.secretRef": obj.GetName(),
            }); err != nil {
                return nil
            }

            requests := make([]ctrl.Request, len(backups.Items))
            for i, b := range backups.Items {
                requests[i] = ctrl.Request{
                    NamespacedName: types.NamespacedName{
                        Name:      b.Name,
                        Namespace: b.Namespace,
                    },
                }
            }
            return requests
        }),
    ).
    Complete(reconciler)

Without the index, this mapping function would need to list all Backups and iterate — potentially thousands of resources checked on every Secret change.

Multi-value indices

The extractor function can return multiple values. This is useful for resources that reference multiple things:

mgr.GetFieldIndexer().IndexField(
    context.Background(),
    &v1alpha1.Pipeline{},
    "spec.stages",
    func(obj client.Object) []string {
        pipeline := obj.(*v1alpha1.Pipeline)
        return pipeline.Spec.Stages // []string{"build", "test", "deploy"}
    },
)

// Find all Pipelines that include a "test" stage
r.List(ctx, &pipelines, client.MatchingFields{"spec.stages": "test"})

When to add indices

When you use client.MatchingFields in List calls. If you’re filtering results after listing, an index will make it faster and cheaper.

When you use EnqueueRequestsFromMapFunc. Mapping functions run frequently — on every event for the watched type. An index makes the lookup O(1) instead of O(n).

Don’t index everything. Each index adds memory overhead and processing time when resources change. Only index fields you actually query.

Gotchas

Indices must be registered before the manager starts. Call IndexField in your setup, not inside a reconciler. The cache needs the index definition before it begins populating.

Index names are strings, not type-checked. A typo in the index name ("spec.databse" instead of "spec.database") won’t fail at compile time — it’ll silently return empty results. Use constants for index names.

const IndexDatabase = "spec.database"

The extractor runs on every cache update. If your extractor function is expensive, it will slow down the cache. Keep it simple — just field access and string conversion.