Cypher Conditional Queries

Christoffer Bergman

Director of Engineering, Neo4j

A long-awaited step toward Turing completeness

A Declarative Language

Cypher, like most database query languages, is a declarative language. That means that instead of defining what the computer should do, you define what you want to receive and leave it to the system to figure out how to achieve that.

What this means is that the constructs that are the most common and important for regular (imperative) languages, such as loops (for and while) and conditions (if), are often completely missing in declarative languages or represented in declarative ways.

This is also the case with Cypher. There are variants of them, like REDUCE as a way of looping and CASE as a way of conditioning, but they are just static variants. REDUCE can only loop a fixed number of iterations, and CASE can only condition what value to get as an expression.

With all of that said, there are cases where such constructs can come in handy also for declarative languages. A Cypher query consists of statements that each passes a number of rows to the next statement, which gives it a feeling of imperativeness, and that causes us to sometimes want to take different paths depending on the intermediate state.

This has been possible using subqueries and hacking them with a conditional starting clause using the WHERE keyword, but it has its limitations, is hard to learn and understand, and definitely not pretty.

That’s why native support for conditional queries was added in the new major Cypher version called Cypher 25, released in Neo4j 2025.06.0 (June 2025).

The Use Case — Reshape a Linked List

To demonstrate this, we’ll look at a fairly simple problem, but with a slightly complex answer: the reordering of a linked list. In a graph, a linked list is a series of nodes where each node has a relationship to the next node in the list, except the last one.

A linked list as a graph, starting at 1 and ending at 5

We can create the list seen above with this query (yes, this can also be done with APOC (apoc.nodes.link), but we are sticking to pure Cypher here):

CREATE (a:Item {name: "1"})
CREATE (b:Item {name: "2"})
CREATE (c:Item {name: "3"})
CREATE (d:Item {name: "4"})
CREATE (e:Item {name: "5"})
CREATE (a)-[:NEXT]->(b)
CREATE (b)-[:NEXT]->(c)
CREATE (c)-[:NEXT]->(d)
CREATE (d)-[:NEXT]->(e)

We could of course have a more dynamic query that takes the length of the list as a parameter, but let’s keep it simple.

What we want to do is move one node to another position in the list (say we want to move the item named “3” to before the item named “5”). That would seem like a fairly simple computational problem, right? *It’s trickier than you might think. Or at least it was to me. I am pretty sure that someone smarter than me can devise a much more clever query that will do this more efficiently and with less code.

*There are a lot of special cases to consider, like moving from or to the head or tail of the list. And if the target is specified as what item to place it before, how do we express that we want to move it to the end of the list?

However, I did turn to someone who’s usually smarter than me (no, not the sloth at the local zoo): ChatGPT. Well, the query it gave me was more complex than the one I had done, and the result after attempting a reordering with it was this:

A linked list after an attempted reordering by the query proposed by ChatGPT

An Implementation Without Conditional Queries

So our goal is a query that takes two parameters: an item to move (the parameter $move should hold the name of the item to move) and a position to move it to ($insertBefore should hold the name of the item to insert it before, and if it doesn’t match any item, it’ll place the moved item last in the list).

What makes the query slightly complex is that there are many special cases. We need to consider the cases where the moved item is the first or last item, and we also have to handle when it’s placed first or last.

Let’s create our query, but without the use of conditional queries. The first thing we need to do is to locate the two items representing our parameters. As you see, we use OPTIONAL MATCH to locate $insertBefore, and that’s because it won’t match anything if we want the moved item placed last in the list.

// Find the cell to move and the cell to insert it before
// (which may be null to insert at the end of the list)
MATCH (move:Item) WHERE move.name = $move
OPTIONAL MATCH (insertBefore:Item) WHERE insertBefore.name = $insertBefore

Now we want to see if the place we ask to have it moved is the same place it already is. If this is the case, we won’t do anything at all, both because it’s unnecessary and because, as it’s written, our query may corrupt the list in that case.

// If we ask to have it placed at the same position, dont do anything
WITH move, insertBefore
WHERE (insertBefore IS NULL OR move <> insertBefore) AND // *1
(NOT EXISTS {(move)-[:NEXT]->(insertBefore)}) AND // *2
(insertBefore IS NOT NULL OR EXISTS{(move)-[:NEXT]->()}) // *3
  1. The first check makes sure the moved item is different from the one it’s moved to (as that wouldn’t have any effect), or that the position moved to is NULL (which would place it last in the list).
  2. The second check makes sure the moved item isn’t already directly before the one it’s moved to be before (which also wouldn’t have any effect).
  3. The third check makes sure the moved item isn’t already the last item in the list in case the requested position is last in the list (which is the third case that wouldn’t have any effect).

We need to detach the item to move from the graph by deleting the relationships coming to it and going from it. We use OPTIONAL MATCH again to handle the case where the item is first or last, but we don’t need any special check for that since DELETE won’t do anything if it’s passed null.

// Find the items before and after the item to move (if they exist)
OPTIONAL MATCH (beforeMove:Item)-[relBeforeMove:NEXT]->(move)
OPTIONAL MATCH (move)-[relAfterMove:NEXT]->(afterMove:Item)

// Disconnect the item to move
DELETE relBeforeMove
DELETE relAfterMove

After that, we need to find the spot where it should be inserted and delete the relationship that’s there:

// Now locate the item to insert it after (if any)
WITH move, insertBefore, beforeMove, afterMove
OPTIONAL MATCH (insertAfter:Item)-[oldRel:NEXT]->(insertBefore)

// Delete the old link to make place for the moved item
DELETE oldRel

Now we’ve located all items we need, we’ve made the surgical incisions needed, and it’s time to patch the entire list up again, but in the new order. This is where all the special cases come in.

The first special case comes when we want to patch the link where the move item was removed. This patch is not needed if the moved item was first or last in the list because there’s nothing to patch it up to.

Here’s where an if statement would’ve come in handy. Instead, we’ll solve it with a CALL subquery and condition it with a WHERE clause:

// Now patch up the link where the item was removed (unless in start or end)
WITH move, insertBefore, insertAfter, beforeMove, afterMove
CALL(beforeMove, afterMove) {
WITH *
WHERE beforeMove IS NOT NULL AND afterMove IS NOT NULL
CREATE (beforeMove)-[:NEXT]->(afterMove)
}

You may not recognize this syntax for CALL(params). It was introduced in Neo4j 5.23 (August 2024). Prior to that, you couldn’t pass parameters in the CALL clause; you had to have another WITH params, preceding the WITH in the example above.

Well, that wasn’t too bad after all. But now comes the insertion of the moved item. Here we have three different cases: when it’s put last in the list, when it’s put first in the list, and when it’s put somewhere in between.

// Now we need to insert the moved item, but how we do that depends
// on where it goes
CALL(move, insertBefore, insertAfter) {
// This is when it is inserted last in the list
WITH *
WHERE insertBefore IS NULL // insertAfter will always be NULL in this case
MATCH (lastElement:Item)
WHERE NOT (lastElement)-[:NEXT]->() AND lastElement <> move
CREATE (lastElement)-[:NEXT]->(move)
}
CALL(move, insertBefore, insertAfter) {
// This is when it is inserted first in the list
WITH *
WHERE insertAfter IS NULL AND insertBefore IS NOT NULL
CREATE (move)-[:NEXT]->(insertBefore)
}
CALL(move, insertBefore, insertAfter) {
// And this is where it is inserted in the middle
WITH *
WHERE insertAfter IS NOT NULL AND insertBefore IS NOT NULL
CREATE (insertAfter)-[:NEXT]->(move)
CREATE (move)-[:NEXT]->(insertBefore)
}

Still doable, but rather verbose. And one issue we see here is that when doing independent conditional subqueries using CALL and WHERE, we essentially get what in Java and similar languages would be:

if(...) {
...
}
if(...) {
...
}
if(...) {
...
}

But what we often want, and that we would have wanted here, is:

if(...) {
...
}
else if(...) {
...
}
else {
...
}

Since we don’t have that, we need to make the WHERE checks more complex by not only checking the current condition but also excluding the previous condition. For example, in the second CALL above, we had:

WHERE insertAfter IS NULL AND insertBefore IS NOT NULL

But we had already covered the case where insertBefore is null in the previous CALL, and it would’ve been good to skip it.

If the number of different conditions is large, it just gets more complex with each step, and the final else case (that would normally be a very simple one) becomes complex since it has to exclude all the cases above it.

Enter Conditional Queries

I must apologize to you. I lied. The subtitle of this post is “A step toward Turing completeness,” which is a blatant lie. I just had to catch your attention. Everything we can do now with conditional queries, we could do before as well — it’s just that we can do it in a nicer and less verbose way.

The keywords for conditional queries are WHEN … THEN and ELSE, but they need to be wrapped in a CALL().

Let’s start by looking at the first conditional call above:

// Now patch up the link where the item was removed (unless in start or end)
WITH move, insertBefore, insertAfter, beforeMove, afterMove
CALL(beforeMove, afterMove) {
WITH *
WHERE beforeMove IS NOT NULL AND afterMove IS NOT NULL
CREATE (beforeMove)-[:NEXT]->(afterMove)
}

Rewriting this with conditional queries looks like this:

// Now patch up the link where the item was removed (unless in start or end)
CALL(beforeMove, afterMove) {
WHEN beforeMove IS NOT NULL AND afterMove IS NOT NULL THEN
CREATE (beforeMove)-[:NEXT]->(afterMove)
}

Cool, but why was the WITH before the CALL removed? That doesn’t seem to be related to the conditional query. No, that’s another feature of Cypher 25. In the past, you had to have a WITH between a write and a following read. That’s not needed anymore.

Not a huge difference, but a bit nicer at least. But why do we still need to wrap it in a CALL statement? It’s because WHEN is a top-level clause and starts a full subquery. This can be avoided by using NEXT, which is another new feature of Cypher 25, but in our case, it’d produce more code, so we won’t use it here. See the Cypher cheat sheet (third example) for an example of that.

The bigger difference comes when we rewrite the second conditional part, the part where the moved node is inserted. The rewritten variant of that part looks like this:

// Now we need to insert the moved item, but how we do that depends
// on where it goes
CALL(move, insertBefore, insertAfter) {
WHEN insertBefore IS NULL THEN { // insertAfter will always be NULL in this case
// This is when it is inserted last in the list
MATCH (lastElement:Item)
WHERE NOT (lastElement)-[:NEXT]->() AND lastElement <> move
CREATE (lastElement)-[:NEXT]->(move)
}
WHEN insertAfter IS NULL THEN {
// This is when it is inserted first in the list
CREATE (move)-[:NEXT]->(insertBefore)
}
ELSE {
// And this is where it is inserted in the middle
CREATE (insertAfter)-[:NEXT]->(move)
CREATE (move)-[:NEXT]->(insertBefore)
}
}

Here, we can see the reduced amount of code and, more importantly, the query is easier to read, understand, and maintain. We only need one CALL for the entire statement, and we reduce each condition. But most importantly, since we have the else logic, we can trim the check for each WHEN now that we don’t have to exclude the checks in the previous WHEN.

But what if we actually want if-if-if logic instead of if-else if-else if logic? In that case, we simply wrap each WHEN in its own CALL. That way, we disconnect the WHEN statements from each other.

The Complete Query

Let’s look at the complete queries so we can compare them side by side.

Here’s the query to reorder a linked list, prior to Cypher 25:

// Find the cell to move and the cell to insert it before
// (which may be null to insert at the end of the list)
MATCH (move:Item) WHERE move.name = $move
OPTIONAL MATCH (insertBefore:Item) WHERE insertBefore.name = $insertBefore

// If we ask to have it placed at the same position, dont do anything
WITH move, insertBefore
WHERE (insertBefore IS NULL OR move <> insertBefore) AND
(NOT EXISTS {(move)-[:NEXT]->(insertBefore)}) AND
(insertBefore IS NOT NULL OR EXISTS{(move)-[:NEXT]->()})

// Find the items before and after the item to move (if they exist)
OPTIONAL MATCH (beforeMove:Item)-[relBeforeMove:NEXT]->(move)
OPTIONAL MATCH (move)-[relAfterMove:NEXT]->(afterMove:Item)

// Disconnect the item to move
DELETE relBeforeMove
DELETE relAfterMove

// Now locate the item to insert it after (if any)
WITH move, insertBefore, beforeMove, afterMove
OPTIONAL MATCH (insertAfter:Item)-[oldRel:NEXT]->(insertBefore)

// Delete the old link to make place for the moved item
DELETE oldRel

// Now patch up the link where the item was removed (unless in start or end)
WITH move, insertBefore, insertAfter, beforeMove, afterMove
CALL(beforeMove, afterMove) {
WITH *
WHERE beforeMove IS NOT NULL AND afterMove IS NOT NULL
CREATE (beforeMove)-[:NEXT]->(afterMove)
}

// Now we need to insert the moved item, but how we do that depends
// on where it goes
CALL(move, insertBefore, insertAfter) {
// This is when it is inserted last in the list
WITH *
WHERE insertBefore IS NULL // insertAfter will always be NULL in this case
MATCH (lastElement:Item)
WHERE NOT (lastElement)-[:NEXT]->() AND lastElement <> move
CREATE (lastElement)-[:NEXT]->(move)
}
CALL(move, insertBefore, insertAfter) {
// This is when it is inserted first in the list
WITH *
WHERE insertAfter IS NULL AND insertBefore IS NOT NULL
CREATE (move)-[:NEXT]->(insertBefore)
}
CALL(move, insertBefore, insertAfter) {
// And this is where it is inserted in the middle
WITH *
WHERE insertAfter IS NOT NULL AND insertBefore IS NOT NULL
CREATE (insertAfter)-[:NEXT]->(move)
CREATE (move)-[:NEXT]->(insertBefore)
}

And here’s the same query, using the new conditional queries:

// Find the cell to move and the cell to insert it before
// (which may be null to insert at the end of the list)
MATCH (move:Item) WHERE move.name = $move
OPTIONAL MATCH (insertBefore:Item) WHERE insertBefore.name = $insertBefore

// If we ask to have it placed at the same position, dont do anything
FILTER (insertBefore IS NULL OR move <> insertBefore) AND
(NOT EXISTS {(move)-[:NEXT]->(insertBefore)}) AND
(insertBefore IS NOT NULL OR EXISTS{(move)-[:NEXT]->()})

// Find the items before and after the item to move (if they exist)
OPTIONAL MATCH (beforeMove:Item)-[relBeforeMove:NEXT]->(move)
OPTIONAL MATCH (move)-[relAfterMove:NEXT]->(afterMove:Item)

// Disconnect the item to move
DELETE relBeforeMove
DELETE relAfterMove

// Now locate the item to insert it after (if any)
OPTIONAL MATCH (insertAfter:Item)-[oldRel:NEXT]->(insertBefore)

// Delete the old link to make place for the moved item
DELETE oldRel

// Now patch up the link where the item was removed (unless in start or end)
CALL(beforeMove, afterMove) {
WHEN beforeMove IS NOT NULL AND afterMove IS NOT NULL THEN
CREATE (beforeMove)-[:NEXT]->(afterMove)
}

// Now we need to insert the moved item, but how we do that depends
// on where it goes
CALL(move, insertBefore, insertAfter) {
WHEN insertBefore IS NULL THEN { // insertAfter will always be NULL in this case
// This is when it is inserted last in the list
MATCH (lastElement:Item)
WHERE NOT (lastElement)-[:NEXT]->() AND lastElement <> move
CREATE (lastElement)-[:NEXT]->(move)
}
WHEN insertAfter IS NULL THEN {
// This is when it is inserted first in the list
CREATE (move)-[:NEXT]->(insertBefore)
}
ELSE {
// And this is where it is inserted in the middle
CREATE (insertAfter)-[:NEXT]->(move)
CREATE (move)-[:NEXT]->(insertBefore)
}
}

Wait a minute — you may say there are more changes than conditional queries here! Yes, there are the removed WITH statements that are no longer needed between write and read, as explained above. But we have added the new FILTER keyword, also introduced in Cypher 25. This basically combines a WITH and associated WHERE into one clause.

A side-by-side comparison using the Neo4j extension for Visual Studio Code

Let’s try it using the example data set we looked at in the beginning and these parameters:

:param {
move: "3",
insertBefore: "5"
}

The result (whether you use the Cypher 25 features or not):

The linked list with item 3 moved to be before item 5

Summary

That’s it. Now you know how to use the new conditional queries. And you know it hasn’t given you the ability to do anything you couldn’t do before, but you can write nicer queries. And as a bonus, you know how to reorder linked lists in pure Cypher.

You can learn more about conditional queries in the Cypher Documentation and the Cypher Cheat Sheet.


Cypher Conditional Queries was originally published in Neo4j Developer Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.