Property Indexes
g.v().has_label("person").has("title", "CEO") reads every person vertex and compares its
title. On a large graph that dominates the query time even when only a few vertices match. A
property index orders the elements of one label by the value of one property, so the same
query reads only the matching vertices.
// Declare: the `person` vertices, ordered by `age`.
g.create_index(#{label: "person", key: "age"});
// Served: equality, P.within and ranges on `age` after has_label("person").
g.v().has_label("person").has("age", 29).values("name").to_list();
g.v().has_label("person").has("age", P.between(30, 40)).values("name").to_list();
g.indexes() // the indexes and what they hold
Indexes are declared, never automatic: nothing is indexed until you ask for it, and an index costs memory and a little time on every write of its key (below).
Declare, list, drop
| DSL | Rust (GraphTraversalSource) | Python (Graph) |
|---|---|---|
g.create_index(#{label, key, element?, name?, type?}) / g.createIndex(..) | create_index(name, PropertyIndexDefinition::new(kind, label, key)), .with_type(PropertyIndexType::MemoryOrdered) | graph.create_index(label, key, element="vertex", name=None, type="memory-ordered") |
g.indexes() | list_indexes() | graph.indexes() |
g.drop_index(name) / g.dropIndex(name) | drop_index(name) | graph.drop_index(name) |
elementis"vertex"(the default) or"edge";labelis one vertex or edge label (it need not exist yet);keyis one top-level property name (a plain name, not a jpath: nested values are not indexed).namedefaults to<label>_<key>(<label>_<key>_edgefor an edge index), with every character that is not a letter, a digit or_replaced by_. It is an identifier like a saved query's name.typeis the index type,"memory-ordered"by default (the only type this version knows).- One index per
(element, label, key, type): a second one under another name fails with a Conflicting property index error that names the first. Declaring the same name again replaces the index. g.indexes()lists each index with itstype,served(whether it is served, with areasonwhen it is not: see unknown types and other storages) and its currententries(indexed elements),distinct_valuesandbytes(an estimate of its memory).
g.create_index(#{element: "edge", label: "knows", key: "weight"});
g.e().has_label("knows").has("weight", P.gt(0.6)).count().next()
Index types
Every index has a type (the key type): how it keeps its entries, which values it holds and
which conditions it serves. This version knows one type, and it is the default:
| Type | What it is | Serves |
|---|---|---|
memory-ordered | an ordered index kept in memory; its entries are derived from the data and rebuilt whenever the graph is loaded (below) | equality, P.within and the ranges (below) |
Which value types an index type holds (Rust: PropertyIndexType::holds, the one place the engine
and the front ends take it from):
| Value type | memory-ordered |
|---|---|
null, boolean | yes |
int64, float64 | yes (ranged by their value as float64) |
string (also a compressed one, by its text) | yes |
uuid | yes |
date, localdatetime, datetime, time, duration | yes |
array (a list), object (a map) | no |
vector | no (vectors are searched with nearest() and similarity()) |
A value the index does not hold is skipped: the element is not in the index, and a condition on such a value is answered by a scan, so results never change.
g.create_index(#{label: "person", key: "age", type: "memory-ordered"}) // the default, spelled out
g.create_index(#{label: "person", key: "age", type: "hash"}) // refused: not a type this version knows
Checked against the schema when created
When the graph's stored schema declares the key at the top level of the
index's label (a vertex label for a vertex index, an edge label for an edge index) and none of
the declared types, null aside, can be held by the index type (a vector, a list, a map, or an
anyOf of only such), creating the index is refused, with a message that names the label, the
key, the declared type and the index type:
// refused: the schema declares `embedding` as a vector
{
g.set_schema(#{mode: "open", vertices: #{doc: #{schema: #{type: "object", properties: #{
embedding: #{type: "array", format: "vector", element: "f32"}}}}}});
g.create_index(#{label: "doc", key: "embedding"})
}
Error: Cannot create a property index on vertex 'doc' key 'embedding': the schema declares
'embedding' as vector<f32>, and an index of type 'memory-ordered' holds none of its values
Help: Vectors are not index keys: search them with nearest() or similarity() (an exact search,
book page "Vectors"), for example g.v().has_label("doc").nearest("embedding", query, 10) for the
10 closest; a vector index type is planned. ...
For a list or a map the help suggests indexing a scalar top-level property instead (an index key
is one top-level name; nested fields are not indexed). A declaration with at least one type the
index holds (anyOf of a list and a string, ["string", "null"]), a key the schema does not
declare, a label it does not declare, and a graph without a schema are all accepted. The schema's
mode does not matter (a declaration in mode none counts too). The playground's form shows the
refusal under its Key field.
The check runs when an index is created (the DSL, GraphTraversalSource::create_index,
Python, the playground, the dev server and MCP), never when a definition is stored as it is:
set_definition, apply_change_set, a replay or recovery, a catalog file or a snapshot load
keep what they carry. A later schema change never drops an index: an index whose key the
schema later declares as a vector stays and simply holds nothing of it.
An index type from a newer version
A store (or a catalog file, a change set) written by a newer Graphersal may hold an index of a
type this version does not know. Such a definition is never damage: it is kept in the catalog
exactly as read and written back unchanged (its payload byte for byte through the journal,
checkpoints, backups, forks and snapshots), but it is not served: no entries are built, the
optimizer never reads it, and g.indexes() (and the playground, the dev server and MCP's
list_indexes) shows it with served: false and a reason such as index type 'x' is not
known to this version. It can be dropped like any index. Creating or redefining an index of an
unknown type is refused with an error whose help lists the known types. Indexes of different
types may cover the same (element, label, key).
What is served
A v()/e() start step reads an index when
- it is the start of the traversal (or of a child traversal), the optimizer folded a
has_label(..)into it, and every folded label has an index on the key: the index ofhas_label("person"), or ofhas_label("person", "employee")when both labels have one; - and a
has()on the indexed key follows directly (onlyhas(),has_label(),has_id(),has_key(),has_not()in between):
| Condition | Served |
|---|---|
has(key, value) | equality |
has(key, P.eq(v)), has(key, P.within(a, b, ..)) | one slice per value |
has(key, P.gt/gte/lt/lte(v)) | one range |
has(key, P.between(a, b)), P.inside(a, b) | one range |
has(key, P.outside(a, b)) | two ranges |
P.neq, P.without, text predicates, P.typeOf, and/or/not composites | not served (scan) |
a value that is a list or a map (has("tags", ["a"])) | not served (scan) |
v().has("title", "CEO") without a label, a jpath key | not served (scan) |
With several served conditions the optimizer picks the most selective index from the index
statistics (an equality reads about entries / distinct_values elements of each label, a range
about half the entries).
Results are the same as without the index, always: the index returns a superset of the
matching elements (numbers are ranged by their value as float64, so 29 and 29.0 are both
read), and the start step and the has() step still check every condition on each candidate. The
comparison is exactly that of has(): has("age", 29) matches an int64 29 only,
has("age", P.eq(29.0)) and the ranges compare numbers across int64 and float64, a string never
equals a uuid, null equals only null. Dates and times are index keys with the same rules
(Comparing and Sorting): each of date, localdatetime, datetime,
time and duration is its own kind (a date never equals a datetime or a string, and a range
reads only values of its bounds' type), a datetime is keyed by its instant (12:00Z and
14:00+02:00 are one key), and a duration is keyed by all its fields (P1M and P30D are two
keys) and ranged by its length, a month as 30 days, ties broken by the fields: equality,
P.within and every range on durations are served. Only the order of the elements may differ (the index
reads them by value): use order() when the order matters.
A P.eq("a") with a string inside a child traversal is not served: there a string first names a
path label (where(P.eq("a"))), which an index cannot know.
See it in .profile()
The start step names the index it reads, and the rule property_index is listed:
$ graphersal -e 'g.create_index(#{label: "person", key: "age"})' \
-e 'g.v().has_label("person").has("age", P.gt(30)).values("name").profile()'
Traversal Metrics
Step Call In Out Time % Dur
=====================================================================================================
v(labels: ["person"]) [index: vertex person.age] 1 0 2 55.666µs 7.53
has("age", P.gt(30)) 1 2 2 42.625µs 5.77
values("name") 1 2 2 1.834µs 0.25
TOTAL: execute: 738.958µs 13.55
=====================================================================================================
Optimizer rules applied: source_filter_pushdown, property_index
An equality folded into the start step shows as
v(labels: ["person"]).has("title", "CEO") [index: vertex person.title]. To compare with the scan,
turn the rule off for one query: g.with("optimizer.disabled", ["property_index"]) (see
Property Index Rule).
Kept exact
An index is maintained on every write: adding and dropping elements (a vertex drop takes its edges
out of the edge indexes), writing and removing the key (also through a jpath write that changes
it), adding and removing a vertex label (a vertex with several labels is in the index of each of
them), changing an edge label. A rolled-back unit restores the index with the data; replay,
apply_change_set and a store's recovery rebuild it exactly. A string kept
compressed is indexed by its text (the index holds the plain text, so
indexing a long compressed field costs the memory compression saved).
Persistence
An index is a catalog definition (kind property_index, like saved queries
and compression rules), so its declaration travels with the graph: the journal (WAL), snapshots,
backups, forks, rollbacks and the catalog file. Its entries are never stored: they are rebuilt
from the data whenever a graph is loaded, by a store open, a read-only view (also of a past state),
a backup open, a recovery, a snapshot load. A read-only view is therefore always consistent with
the data it shows. GraphML and GraphSON files carry no catalog, so no index.
Memory
Each indexed element costs one entry: its value (strings longer than 24 bytes on the heap) plus
about 50 bytes of ordered-set bookkeeping. g.memory_usage() reports the indexes as
property_index_bytes (part of total_bytes, so the memory budget sees
them), g.indexes() per index. Building an index reads every element of its label once.
Other storages
Indexes are a capability of the storage (StorageCapabilities::property_indexes); the built-in
graph has it. On a custom storage without it,
create_index fails with unsupported: property indexes; an index definition can still be stored
in its catalog (a catalog file loaded into it, say) and is listed with served: false, but no
query reads it. A storage that serves indexes implements GraphStorage::property_indexes and the
lookups vertices_by_property_index/edges_by_property_index.
In the playground, the dev server and MCP
- Playground (WebAssembly and over the dev server): Catalog ▾ ▸ Property indexes lists the indexes with their type, entries, distinct values, elements per value (what an equality reads) and memory (an index that is not served says why), creates one from a form (element, label, key, type, optional name; the default name is shown as you type; an engine refusal shows under its field) and drops one after a confirmation. The Statistics tab of the Profile panel shows Property indexes with their memory when the graph has some. Indexes travel with the catalog file (Catalog ▾ ▸ Save catalog).
- Dev server:
GET /api/indexes,POST /api/indexes/create{label, key, element?, name?, type?},POST /api/indexes/drop{name}(see the dev server); a create or drop is a commit recorded in the change feed, so open browsers reload. The start block on stderr names the index memory. - MCP: the tools
list_indexes(also with--mcp-read-only),create_index(with an optionaltype, an enum of the known types) anddrop_index(not offered read-only, wherequeryrefusesg.create_indextoo).
Limits
- Not indexed: nested values (jpath keys), lists, maps and vectors as values (see
index types), labels other than the one declared (an index on
persondoes not servev().has("title", ..)withouthas_label). - One index type (
memory-ordered): persisted, hash, vector (approximate nearest neighbour) and full-text types are planned and will be additional values oftype. - No unique indexes (constraints) in this version.
order().by(key)does not read the index.