# Indexes

The [primary index](https://aerospike.com/docs/database/learn/architecture/data-storage/primary-index) gives you constant-time access to any record by key. It is always present, always in memory, and costs 64 bytes per record. Every other index type in Aerospike is optional, carries additional memory cost, and serves a specific query pattern. Use indexes deliberately — not as defaults.

## Primary index recap

The primary index (PI) maps every record’s 20-byte digest to a storage location. It supports one access pattern: look up a record by its full key. If you can derive the key from the data you already have, the PI gives you the fastest path to any record in the cluster.

Most Aerospike data models are designed so that the primary access path is a key lookup or a bounded [batch read](https://aerospike.com/docs/develop/learn/batch/) of known keys. Indexes beyond the PI are for the access patterns where the key is not known in advance.

## Secondary indexes

A [secondary index](https://aerospike.com/docs/database/learn/architecture/data-storage/secondary-index) (SI) lets you query records by bin value across the entire namespace or a specific set. The server maintains a B-tree index on the indexed bin values and evaluates queries by scattering the request to all nodes — each node searches its local partition of the index.

### When to use a secondary index

-   You need to find records by a bin value that you cannot derive from the record key.
-   The query pattern is frequent enough to justify the memory cost.
-   The result set is a bounded subset of the namespace, not a full traversal.

### When not to use a secondary index

-   **The key is derivable.** If you can construct the record key from the query parameters, a direct `get` or batch read is faster and cheaper.
-   **Unique ID resolution.** If the query returns exactly one record (for example, “find the user with email X”), a lookup table is more efficient than an SI (see [Lookup tables for unique ID resolution](#lookup-tables-for-unique-id-resolution)).
-   **Every query pattern.** Do not create an SI for every bin. Many access patterns are better served by key design and batch reads. Each SI adds memory cost per indexed entry.

### Memory cost

Each SI entry costs approximately **14 bytes** (8 bytes for the value or value hash, plus 6 bytes for the PI reference). The B-tree structure uses 4 KiB nodes with an initial pre-allocation of 16 MiB per node.

For large indexes, the server pre-allocates memory in stages controlled by the [`sindex-stage-size`](https://aerospike.com/docs/database/reference/config#namespace__sindex-stage-size) configuration parameter. The default is 1 GiB per stage, with a minimum of 128 MiB. Indexes exceeding 2 TiB require larger stage sizes.

### List and Map indexes

When you create an SI on a bin that contains a List or Map, the index creates one entry per element in the collection, not one entry per record. A Map bin with 100 entries in each of 1 million records produces 100 million SI entries. Factor this into memory planning.

List indexing and Map indexing (on keys or values) are supported at any nesting depth starting with Aerospike Database 6.1. Earlier versions support top-level indexing only. A known limitation: range queries on collection-indexed bins can return duplicate records when a single collection contains multiple values that fall within the query range. Your application must handle deduplication of query results when using range queries on List or Map indexes.

## Set indexes

A [set index](https://aerospike.com/docs/database/learn/architecture/data-storage/set-index) lets you query “all records in set S” without scanning the entire namespace. It costs approximately **16 bytes per record** in the set, multiplied by the replication factor.

Set indexes are useful when the set is small relative to the namespace. If a namespace has 100 million records and a set has 50,000, a set index avoids scanning 99.95% of the namespace. If the set contains a large fraction of the namespace, the set index adds memory cost without meaningful performance gain.

You can enable and disable set indexes dynamically without restarting the server.

## Expression indexes

Starting with Aerospike Database 8.1, [expression indexes](https://aerospike.com/docs/develop/expressions/) let you index a computed value derived from an expression rather than a raw bin value. This gives you two capabilities that standard SIs do not:

### Sparse indexing

Use `cond(..., unknown())` in the index expression to exclude records that do not match a condition. Only the records where the expression evaluates to a definite value are indexed; the rest are skipped.

For example, to index only records where `status == "active"`:

```plaintext
cond(

  eq(bin("status"), "active"), bin("region"),

  unknown()

)
```

This creates an index on the `region` bin, but only for active records. Inactive records produce `unknown()`, which excludes them from the index. The result is a smaller, more memory-efficient index that covers only the records your queries target.

### Computed value indexing

Index a value that does not exist in any single bin — for example, a hash of two bins, a derived category, or a transformed date component. The server evaluates the expression at write time and indexes the result.

### Indexing nested CDT fields with path expressions

Starting with Aerospike Database 8.1.2, you can combine expression indexes with [path expressions](https://aerospike.com/docs/develop/expressions/path/) to index values extracted from nested collection data type structures. Define a `selectByPath` expression that extracts the target values into a list, then create an SI with collection type `LIST` so each extracted value becomes a separate index entry.

For example, if each record has a `vehicles` bin holding a List of Maps (each with `make`, `color`, and `license` fields), you can build an expression index that extracts all `license` values and indexes them individually. A query for a specific license plate then uses the index to find the matching record without scanning.

Once the expression index exists, queries can reference it by name instead of passing the expression again. Querying by index name avoids rebuilding the expression on the client side and is the simpler production pattern.

For worked multi-language examples of expression index creation and query-by-name, see [Working with nested CDTs](https://aerospike.com/docs/develop/expressions/nesting/).

## Lookup tables for unique ID resolution

When the application needs to resolve an external unique identifier (an email address, an external system ID, a social handle) to an Aerospike record key, a dedicated lookup set is usually better than a secondary index.

Create a set where the record key is the external ID and a single bin holds the corresponding Aerospike key or digest:

```plaintext
Set: email_lookup

Key: "alice@example.com"

Bin "user_key": "user:alice"
```

Resolving the email to a user record is two sequential `get` calls: one to the lookup set, one to the user set. Both are key lookups — constant time, single node.

An SI query for the same purpose scatters to all nodes and searches the index on every node, returning one result. For a unique-value lookup, the SI approach is slower and more resource-intensive than the two-hop key lookup.

Use lookup tables when:

-   The external ID is unique and maps to exactly one record.
-   The lookup is on the hot path and latency matters.
-   You want to avoid the management overhead associated with an SI.

## Namespace-wide secondary indexes

When multiple sets share a bin with the same name and the application queries ranges on that bin across sets, a single SI over the namespace shares the pre-allocation across all sets. This is more memory-efficient than creating a separate SI per set.

If only a subset of sets needs the index, combine a namespace-wide SI with an expression index that uses `cond` to skip irrelevant sets. The expression evaluates the set name and returns `unknown()` for sets that do not participate, producing a sparse index that covers only the relevant records.

## Two-phase execution for filter expressions

When you apply a [filter expression](https://aerospike.com/docs/develop/expressions/) to a query, scan, or batch read, the server evaluates it in two phases:

1.  **Metadata phase.** The server checks conditions that use only record metadata — generation, TTL, last-update time, set name, record size (Database 7.0+). If the metadata condition eliminates the record, the server skips the storage read entirely.
2.  **Storage phase.** If the record passes the metadata phase, the server reads the record from storage and evaluates the remaining conditions against bin values.

Place metadata conditions first in your filter expression to maximize the number of records eliminated before storage I/O. For example, a filter that checks `since_update_time < 3600000` (less than one hour since last update) before checking a bin value avoids reading stale records from disk.

## Index strategy summary

| Tool | Cost per entry | Query pattern | Use when |
| --- | --- | --- | --- |
| Primary index | ~64 bytes/record (always present) | Key lookup | Key is known or derivable |
| Secondary index | ~14 bytes/entry + B-tree overhead | Bin value query (scatter to all nodes) | Key is not derivable, result set is bounded |
| Set index | ~16 bytes/record × RF | All records in a set | Set is small relative to namespace |
| Expression index | Same as SI, but sparse | Computed or conditional bin value query | Subset of records, derived values |
| Lookup table | 64 bytes PI + small record | Unique ID resolution (two key lookups) | External ID maps to one record, hot path |