Indexes and constraints#
A property index is a PostgreSQL btree over an expression that reads one key out of a
label’s jsonb property bag. That single sentence explains almost everything about when one
helps. It helps exactly when the planner can match your predicate against that expression.
It does nothing at all when it cannot.
This page determines, by experiment, which predicates qualify. The answers are not
symmetrical and several of them are surprising, so the tables below are the point of the
page. How a graph is stored
shows what an index looks like on disk. The full syntax is in
CREATE PROPERTY INDEX and
CREATE CONSTRAINT.
A graph to measure on#
The example dataset has 18 films, which is too few for the planner to prefer any index over reading the table. Build a bigger label to work on, and drop it at the end of the page:
CREATE TABLE seed_film AS
SELECT 'Film ' || lpad(g::text, 6, '0') AS title,
1900 + (g % 130) AS year,
(ARRAY['Action','Sci-Fi','Drama','Comedy'])[1 + (g % 4)] AS genre
FROM generate_series(1, 50000) g;
CREATE GRAPH idxlab;
SET graph_path = idxlab;
CREATE VLABEL film;
LOAD FROM seed_film AS r CREATE (:film {title: r.title, year: r.year, genre: r.genre});
CREATE PROPERTY INDEX ON film (title);
CREATE PROPERTY INDEX ON film (year);
ANALYZE idxlab.film;
ANALYZE matters as much as the index. An index the planner has no statistics for is an
index the planner will decline to use.
Every plan below is the planner’s own unforced choice on 50,000 rows. Nothing is turned off to make a point.
What an index can serve#
Equality is the case everything else is measured against:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title = 'Film 000042' RETURN f.year;
QUERY PLAN
-------------------------------------------------------------
Index Scan using film_title_idx on film f
Index Cond: (properties.'title' = '"Film 000042"'::jsonb)
Index Cond is the word to look for. It means the predicate became a search of the index.
A predicate that appears as Filter instead was evaluated on rows the scan had already
fetched. That is the thing the index was supposed to avoid.
A property map in the pattern compiles to the same equality and gets the same plan:
EXPLAIN (COSTS OFF) MATCH (f:film {title: 'Film 000042'}) RETURN f.year;
QUERY PLAN
-------------------------------------------------------------
Index Scan using film_title_idx on film f
Index Cond: (properties.'title' = '"Film 000042"'::jsonb)
A range works, because a btree is ordered:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.year >= 2025 AND f.year < 2030 RETURN f.title;
QUERY PLAN
----------------------------------------------------------------------------------------------------
Bitmap Heap Scan on film f
Recheck Cond: ((properties.'year' >= '2025'::jsonb) AND (properties.'year' < '2030'::jsonb))
-> Bitmap Index Scan on film_year_idx
Index Cond: ((properties.'year' >= '2025'::jsonb) AND (properties.'year' < '2030'::jsonb))
The comparison happens in jsonb’s own ordering, which orders numbers numerically, so
>= 2025 means what you want. But jsonb sorts types before values: a property holding the
string "2025" is not between two numbers.
An IN list of literals becomes an array search:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title IN ['Film 000042', 'Film 000043'] RETURN f.year;
QUERY PLAN
---------------------------------------------------------------------------------------------
Index Scan using film_title_idx on film f
Index Cond: (properties.'title' = ANY ('{"\"Film 000042\"","\"Film 000043\""}'::jsonb[]))
What an index cannot serve#
<> cannot use an index, for the ordinary reason that “not this one value” is most of the
table:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.year <> 2020 RETURN f.title;
QUERY PLAN
------------------------------------------------
Seq Scan on film f
Filter: (properties.'year' <> '2020'::jsonb)
The string predicates are the ones that catch people, because STARTS WITH looks like it
should be a prefix range:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title STARTS WITH 'Film 00004' RETURN f.year;
QUERY PLAN
-------------------------------------------------------------------------
Seq Scan on film f
Filter: string_starts_with(properties.'title', '"Film 00004"'::jsonb)
It is not. STARTS WITH compiles to a function call, string_starts_with, and a btree over
properties.'title' has no operator class entry for that function. So there is no index path
at all, not merely a rejected one. Turning enable_seqscan off does not produce one; the
plan stays a sequential scan and is marked Disabled: true.
CONTAINS, ENDS WITH and =~ behave identically, each compiling to its own function:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title CONTAINS '00042' RETURN f.year;
QUERY PLAN
-----------------------------------------------------------------
Seq Scan on film f
Filter: string_contains(properties.'title', '"00042"'::jsonb)
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title =~ '^Film 00004' RETURN f.year;
QUERY PLAN
--------------------------------------------------------------------
Seq Scan on film f
Filter: string_regex(properties.'title', '"^Film 00004"'::jsonb)
IS NULL and IS NOT NULL also fall back to a scan on an unpromoted property:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.director IS NULL RETURN f.title;
QUERY PLAN
-------------------------------------------
Seq Scan on film f
Filter: (properties.'director' IS NULL)
The reason is worth understanding, because it also tells you why IS NULL behaves
differently after promotion. properties.'director' is null for a row whose bag has no
director key at all. An expression index over that key stores a null for every such row, so
the index would have to be read almost end to end.
On a promoted column the same test is a real IS NULL on a real column, and the btree answers
it directly.
The table#
Measured on 50,000 rows with film_title_idx on title and film_year_idx on year, and
with ANALYZE run:
Predicate |
Index used |
|---|---|
|
Yes — |
|
Yes — |
|
Yes — |
|
Yes — |
|
No — |
|
No — |
|
No — |
|
No — |
|
No — |
|
No — |
|
No — |
|
Yes — |
No index type rescues the string predicates for a Cypher predicate: they are function calls, and no operator class claims them. If you need prefix or substring search, full-text search is the tool. You can also reach the column from SQL; see below.
Ordering#
An index serves an ORDER BY on the same property. So a “top N by property” query needs no
sort at all:
EXPLAIN (COSTS OFF) MATCH (f:film) RETURN f.title AS t ORDER BY t LIMIT 5;
QUERY PLAN
-------------------------------------------------
Limit
-> Index Scan using film_title_idx on film f
This works because a sort key is ordered as the value it is rather than as a jsonb rendering of it. Ordering by an expression the index does not hold still sorts.
Composite and partial indexes#
A property index takes several keys, and the leftmost-prefix rule is PostgreSQL’s:
CREATE PROPERTY INDEX ON film (genre, year);
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.genre = 'Comedy' AND f.year >= 2025 RETURN f.title;
QUERY PLAN
---------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on film f
Recheck Cond: ((properties.'genre' = '"Comedy"'::jsonb) AND (properties.'year' >= '2025'::jsonb))
-> Bitmap Index Scan on film_genre_year_idx
Index Cond: ((properties.'genre' = '"Comedy"'::jsonb) AND (properties.'year' >= '2025'::jsonb))
A query on year alone cannot use it and goes back to the single-key index on year. So
order the keys equality-first, as you would in SQL.
A WHERE clause on the index makes it partial. That is how you index a small, hot subset of
a large label:
CREATE PROPERTY INDEX film_comedy_year_idx ON film (year) WHERE genre = 'Comedy';
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.genre = 'Comedy' AND f.year >= 2025 RETURN f.title;
QUERY PLAN
-----------------------------------------------------------------------------------------------------
Bitmap Heap Scan on film f
Recheck Cond: ((properties.'year' >= '2025'::jsonb) AND (properties.'genre' = '"Comedy"'::jsonb))
-> Bitmap Index Scan on film_comedy_year_idx
Index Cond: (properties.'year' >= '2025'::jsonb)
The genre test has left the index condition, because every entry in this index is a comedy
already; it survives only in the recheck. The index is a quarter of the size of the full one
on year: 524,288 bytes against 2,056,192. The planner picked it over both alternatives.
CREATE PROPERTY INDEX also takes USING btree | hash | gin | brin | gist | spgist,
CONCURRENTLY, IF NOT EXISTS, INCLUDE and a tablespace. All of them mean what they mean
in PostgreSQL.
Note
Every bare name inside CREATE PROPERTY INDEX (...) is a property key, including the word
properties. CREATE PROPERTY INDEX ON film USING gin (properties) indexes a property
named properties, which no film has. To index the bag itself, use plain SQL —
CREATE INDEX film_bag_idx ON idxlab.film USING gin (properties) — and note that only a SQL
predicate such as properties @> '{"genre": "Comedy"}' can use it. A Cypher property read
compiles to properties.'genre', which a GIN index on the bag does not match.
What promotion changes#
Promoting a property gives it a real typed column, and every predicate is then compiled against that column instead of against the bag. Promoting a property to a column is the page for why and how. What matters here is that it changes the answers in the table above, in both directions.
ALTER VLABEL film ADD COLUMN year int GENERATED;
ALTER VLABEL film ADD COLUMN title text GENERATED;
Start with what promotion does to the indexes you already have. An index over
properties.'year' is matched by nothing, because no query compiles to that expression any
more:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.year >= 2025 AND f.year < 2030 RETURN f.title;
QUERY PLAN
----------------------------------------------
Seq Scan on film f
Filter: ((year >= 2025) AND (year < 2030))
The index is still there, still being maintained on every write, and no longer used by anything. Promotion is therefore always two steps: promote, then rebuild the index on the column.
Rebuilding as a property index works for a bare key, which is the common case and the one
below. An expression over the key needs the other form. CREATE PROPERTY INDEX compiles a
bare key to the promoted column, but an expression over that key to the jsonb bag, which is
not what the query reads. Write that one as SQL over the column, which \dGi still lists:
CREATE INDEX film_lower_idx ON idxlab.film (lower(title));
See An expression property index is built over the bag. See An expression property index is built over the bag.
DROP PROPERTY INDEX film_year_idx;
DROP PROPERTY INDEX film_title_idx;
DROP PROPERTY INDEX film_genre_year_idx;
DROP PROPERTY INDEX film_comedy_year_idx;
CREATE PROPERTY INDEX ON film (year);
CREATE PROPERTY INDEX ON film (title);
The label now looks like this. The new indexes are ordinary column indexes, not expression indexes:
\d idxlab.film
Table "idxlab.film"
Column | Type | Collation | Nullable | Default
------------+---------+-----------+----------+-----------------------------------------------------------------------------------------------
id | graphid | | not null | graphid(graph_labid('idxlab.film'::cstring), nextval('idxlab.film_id_seq'::regclass))
properties | jsonb | | not null | jsonb_build_object()
year | integer | | | generated always as (ag_property_text(properties, 'year'::text, 'n'::"char")::integer) stored
title | text | | | generated always as (ag_property_text(properties, 'title'::text, 's'::"char")) stored
Indexes:
"film_pkey" PRIMARY KEY, btree (id)
"film_bag_idx" gin (properties)
"film_title_idx" btree (title)
"film_year_idx" btree (year)
Inherits: idxlab.ag_vertex
Equality and range now compare native values rather than jsonb:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title = 'Film 000042' RETURN f.year;
QUERY PLAN
---------------------------------------------
Index Scan using film_title_idx on film f
Index Cond: (title = 'Film 000042'::text)
IS NULL becomes sargable, which it was not before:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title IS NULL RETURN f.year;
QUERY PLAN
-------------------------------------------
Index Scan using film_title_idx on film f
Index Cond: (title IS NULL)
ORDER BY improves from an index scan to an index-only scan, because the value the query
wants is in the index:
EXPLAIN (COSTS OFF) MATCH (f:film) RETURN f.title AS t ORDER BY t LIMIT 5;
QUERY PLAN
------------------------------------------------------
Limit
-> Index Only Scan using film_title_idx on film f
And two predicates get worse. IN no longer uses the index, because the column is boxed
back to jsonb to be compared against a jsonb list:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title IN ['Film 000042', 'Film 000043'] RETURN f.year;
QUERY PLAN
---------------------------------------------------------------------------------------------
Seq Scan on film f
Filter: (cypher_to_jsonb(title) = ANY ('{"\"Film 000042\"","\"Film 000043\""}'::jsonb[]))
STARTS WITH is boxed the same way, so promotion does not rescue it either:
EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title STARTS WITH 'Film 00004' RETURN f.year;
QUERY PLAN
-----------------------------------------------------------------------------
Seq Scan on film f
Filter: string_starts_with(cypher_to_jsonb(title), '"Film 00004"'::jsonb)
But the column is a real text column, so SQL can reach it with a real text operator, and
that is a prefix range:
EXPLAIN (COSTS OFF) SELECT count(*) FROM idxlab.film WHERE title LIKE 'Film 00004%';
QUERY PLAN
--------------------------------------------------------------------------------------
Aggregate
-> Index Only Scan using film_title_idx on film
Index Cond: ((title >= 'Film 00004'::text) AND (title < 'Film 00005'::text))
Filter: (title ~~ 'Film 00004%'::text)
That is the practical answer for prefix search on a hot key: promote the property and write the prefix predicate in SQL, wrapping the Cypher part if you need one. See Combining SQL and Cypher.
The table, after promotion#
Predicate |
Unpromoted (jsonb) |
Promoted (typed column) |
|---|---|---|
|
Yes |
Yes |
|
Yes |
Yes |
|
Yes |
No — boxed to jsonb |
|
No |
No |
|
No |
No |
|
No |
No |
|
No |
No |
|
No |
Yes |
|
Yes — index scan |
Yes — index-only scan |
SQL |
Not applicable |
Yes — prefix range |
Across a label hierarchy#
An index belongs to one table. A pattern on a parent label scans the parent and every label below it, and each of those needs its own index. Build a family to see it:
CREATE TABLE seed_work AS SELECT g FROM generate_series(1, 10000) g;
CREATE VLABEL work;
CREATE VLABEL short INHERITS (work);
CREATE VLABEL feature INHERITS (work);
LOAD FROM seed_work AS r CREATE (:short {code: 'S' || lpad((r.g)::text, 6, '0')});
LOAD FROM seed_work AS r CREATE (:feature {code: 'F' || lpad((r.g)::text, 6, '0')});
CREATE PROPERTY INDEX ON work (code);
ANALYZE idxlab.work; ANALYZE idxlab.short; ANALYZE idxlab.feature;
Only work is indexed, and every child is read in full:
EXPLAIN (COSTS OFF) MATCH (w:work) WHERE w.code = 'F000042' RETURN w;
QUERY PLAN
----------------------------------------------------------------
Result
-> Append
-> Seq Scan on work w_1
Filter: (properties.'code' = '"F000042"'::jsonb)
-> Seq Scan on short w_2
Filter: (properties.'code' = '"F000042"'::jsonb)
-> Seq Scan on feature w_3
Filter: (properties.'code' = '"F000042"'::jsonb)
Index each child and every branch of the Append gets one:
CREATE PROPERTY INDEX ON short (code);
CREATE PROPERTY INDEX ON feature (code);
QUERY PLAN
--------------------------------------------------------------------
Result
-> Append
-> Seq Scan on work w_1
Filter: (properties.'code' = '"F000042"'::jsonb)
-> Index Scan using short_code_idx on short w_2
Index Cond: (properties.'code' = '"F000042"'::jsonb)
-> Index Scan using feature_code_idx on feature w_3
Index Cond: (properties.'code' = '"F000042"'::jsonb)
work itself keeps its sequential scan because it holds no rows of its own, and reading an
empty table is cheaper than opening an index. That is the right decision. It is also the
shape to watch for: a hierarchy costs you one index per label, forever, for every property
you search a family by.
Constraints#
Two forms exist, and they are the two PostgreSQL forms underneath.
ASSERT <key> IS UNIQUE builds an exclusion constraint over the key’s expression:
CREATE VLABEL account;
CREATE CONSTRAINT account_email_uq ON account ASSERT email IS UNIQUE;
CREATE (:account {email: 'a@example.com', age: 30});
CREATE (:account {email: 'a@example.com', age: 31});
ERROR: conflicting key value violates exclusion constraint "account_email_uq"
DETAIL: Key ((properties.'email'))=("a@example.com") conflicts with existing key ((properties.'email'))=("a@example.com").
ASSERT <predicate> builds a check constraint:
CREATE CONSTRAINT account_age_ck ON account ASSERT age > 0;
CREATE (:account {email: 'b@example.com', age: -1});
ERROR: new row for relation "account" violates check constraint "account_age_ck"
DETAIL: Failing row contains (7.3, {"age": -1, "email": "b@example.com"}).
Both are visible as ordinary constraints, and the unique one is backed by a real btree over the expression, so it doubles as an index for lookups on that key:
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = 'idxlab.account'::regclass AND contype IN ('c', 'x') ORDER BY conname;
conname | pg_get_constraintdef
------------------+---------------------------------------------------
account_age_ck | CHECK ((properties.'age' > cypher_to_jsonb(0)))
account_email_uq | EXCLUDE USING btree ((properties.'email') WITH =)
Neither constraint says anything about a vertex that lacks the property. A missing key reads as null, a null is not equal to another null, and a check constraint on an unknown value passes:
CREATE (:account {email: 'c@example.com'});
CREATE (:account {age: 44});
CREATE (:account {age: 45});
MATCH (a:account) RETURN a.email AS email, a.age AS age ORDER BY email NULLS LAST, age;
email | age
-----------------+-----
"a@example.com" | 30
"c@example.com" |
| 44
| 45
Two accounts with no email at all coexist happily. If a key must be present as well as unique, add a second constraint asserting that it is not null. A property bag will not do it for you.
CREATE UNIQUE PROPERTY INDEX is the other way to spell uniqueness, and it produces a
unique index rather than an exclusion constraint:
CREATE VLABEL member;
CREATE UNIQUE PROPERTY INDEX ON member (card);
CREATE (:member {card: '0001'});
CREATE (:member {card: '0001'});
ERROR: duplicate key value violates unique constraint "member_card_idx"
DETAIL: Key ((properties.'card'))=("0001") already exists.
Prefer it for a fast unique lookup. Prefer the named constraint for a rule you want to see in
pg_constraint and in a dump under a name you chose. Neither is inherited by a child label:
a constraint on a parent constrains only the parent’s own rows.
Practical order of work#
The sequence that answers most “why is this slow” questions, in order:
ANALYZEthe labels involved, edge labels included. An unanalysed edge label costs more than a missing index; see Tuning a query.Index the property your pattern is anchored on: the one selective equality that picks the starting vertices. Everything downstream is a join through the edge indexes that already exist.
Check with
EXPLAINthat the predicate arrives asIndex Condand not asFilter.Only then consider promoting the property, which is the answer when the read itself is the cost rather than the search.
Clean up#
DROP GRAPH idxlab CASCADE;
DROP TABLE seed_film;
DROP TABLE seed_work;
Where to go next#
Promoting a property to a column — the other half of the story above.
Tuning a query — plan shape, statistics and the settings that change them.
Full-text search — the indexable answer to substring and prefix matching.