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.
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.
// 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.
// 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.
// 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:
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.
// 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.
Related
- Up: Index Selection & Composite Indexing — where composite indexes fit among the other index types.
- Composite Index vs Full-Text Index Selection — when a composite index is the right tool at all versus tokenised search.
- Query Plan Analysis with EXPLAIN and PROFILE — reading the seek-versus-scan evidence this rule depends on.