Ordering properties in a composite index correctly

You created a composite index on two properties, but one of your queries still scans the whole label — because the property order in a composite index is not cosmetic. A composite index is a single sorted structure keyed on a tuple of properties, and the planner can only seek it by a leading prefix of that tuple. Put the wrong property first and a query that filters only the trailing property cannot use the index at all; put the right property first and both queries seek. This page shows why order determines usability, gives the rule — lead with the property that is always equality-filtered and most selective — and proves which prefixes seek with PROFILE. It is part of index selection and composite indexing.

Prerequisites

Why order decides usability

A composite index on (a, b) sorts entries first by a, then by b within each a. That layout means the index is usable for a predicate on a alone, or on a and b together, but not for a predicate on b alone — the b values are scattered across every a bucket, so there is no contiguous range to seek. This is the leading-prefix rule: only a prefix of the key tuple is seekable. The corollary is a design rule with two parts. First, the leading property must be the one your queries always constrain, because a query that omits it cannot use the index. Second, among the properties you always constrain, lead with the one filtered by equality and carrying the highest selectivity, so the seek lands on the narrowest possible slice before the trailing predicate refines it.

Usable key prefixes of a composite index on (status, processed_at) The composite index key is the tuple status then processed_at. Entries are sorted by status into contiguous groups, and within each status group they are sorted by processed_at. Three queries are evaluated against this layout. A query with status equals a value and processed_at greater than or equal to a timestamp uses the full key and seeks. A query with status alone uses the leading prefix and seeks. A query with processed_at alone has no leading prefix, because processed_at values are spread across every status group, so the planner scans the label instead. Composite key: ( status , processed_at ) — sorted left-to-right status = "ACTIVE" processed_at ↑ sorted 2026-01-03 · 2026-02-11 2026-05-20 · 2026-07-09 status = "CLOSED" processed_at ↑ sorted 2026-01-08 · 2026-03-30 2026-06-02 · 2026-07-14 status = "PENDING" processed_at ↑ sorted 2026-02-01 · 2026-04-17 2026-05-05 · 2026-06-28 WHERE status = $s AND processed_at >= $t ✓ SEEK · full key WHERE status = $s ✓ SEEK · leading prefix WHERE processed_at >= $t ✗ SCAN · no leading prefix
Only a leading prefix of the key seeks. A predicate on the trailing property alone has no contiguous range and falls back to a scan.

Core implementation

Take Transaction nodes filtered by an equality on status and a range on processed_at. The status is always supplied; the timestamp narrows within it. Lead the key with status.

cypher
// CORRECT ORDER. status is the always-present, equality-filtered, high-selectivity
// leading property; processed_at is the trailing range refinement. Both of the
// intended queries can seek a usable prefix of this key.
CREATE INDEX tx_status_time IF NOT EXISTS
FOR (t:Transaction) ON (t.status, t.processed_at);

// Query 1 — both properties: seeks the full key, ordered leaves satisfy ORDER BY.
MATCH (t:Transaction)
WHERE t.status = $status AND t.processed_at >= $since
RETURN t.tx_id
ORDER BY t.processed_at DESC;

// Query 2 — leading property only: still seeks (leading prefix is usable).
MATCH (t:Transaction)
WHERE t.status = $status
RETURN count(t);

If you had reversed the key to (processed_at, status), Query 2 — which filters only status — would have no usable prefix and would scan every Transaction. Worse, leading with a range property is a trap even when both predicates are present: a range on the leading property opens a wide span of the index, and the trailing equality can only filter within it rather than seek. The rule is therefore twofold — lead with the property that is always constrained, and among candidates prefer the one filtered by equality and with the highest selectivity, so the initial seek is as narrow as possible.

cypher
// WRONG ORDER for these queries — a lone-status filter cannot use this index,
// and even the two-predicate query seeks by a range prefix, not an equality one.
CREATE INDEX tx_time_status_BAD IF NOT EXISTS
FOR (t:Transaction) ON (t.processed_at, t.status);

Validation & verification

PROFILE each query and read the leaf operator. The correctly ordered index must produce a NodeIndexSeek for both the full-key and the leading-prefix query.

cypher
// Full key — expect NodeIndexSeek on tx_status_time, no Sort.
PROFILE
MATCH (t:Transaction)
WHERE t.status = $status AND t.processed_at >= $since
RETURN t.tx_id ORDER BY t.processed_at DESC;

// Leading prefix only — expect NodeIndexSeek on the same index.
PROFILE
MATCH (t:Transaction)
WHERE t.status = $status
RETURN count(t);

// Trailing property only — expect NodeByLabelScan + Filter (index NOT used).
// This is the positive proof that order, not mere existence, governs usability.
PROFILE
MATCH (t:Transaction)
WHERE t.processed_at >= $since
RETURN count(t);

If the trailing-only query seeks, you have a separate single-property index on processed_at doing the work — confirm the index inventory so you know which structure served which query:

cypher
SHOW INDEXES YIELD name, labelsOrTypes, properties, state
WHERE 'Transaction' IN labelsOrTypes
RETURN name, properties, state;

Edge cases & gotchas

1. A trailing-property query that must seek needs its own index. If some workload genuinely filters processed_at without status, the composite index cannot help it. Add a dedicated single-property index rather than reordering the composite and breaking the two-predicate query.

cypher
// Serve the standalone processed_at filter without disturbing the composite key.
CREATE INDEX tx_processed_at IF NOT EXISTS
FOR (t:Transaction) ON (t.processed_at);

2. Leading with a low-selectivity property wastes the seek. If status has only two distinct values across millions of nodes, seeking status = 'ACTIVE' still lands on half the store before the range narrows it. When both properties are always present, lead with whichever has more distinct values so the first seek cuts deepest.

3. More than two properties compound the rule. For a key (a, b, c) the usable prefixes are a, (a, b), and (a, b, c) — never (a, c) skipping b, and never b or c alone. Only place a property later in the key if every query that needs it also constrains everything to its left.