User-defined procedures and functions

Defining procedures and functions enables you to extend Neo4j by writing customized code, which can be invoked directly from Cypher. Procedures and functions can take arguments, perform operations on the database, and return results.

  • User-defined procedures are the most powerful form of customization, allowing you to perform complex operations and return multiple results.

  • User-defined functions are simpler forms of procedures that return a single value and are read-only. Although they are less powerful in capability, they are often easier to use and more efficient than procedures for many common tasks.

  • User-defined aggregation functions are functions that aggregate data and return a single result.

For a comparison between user-defined procedures, functions, and aggregation functions, see Comparison of procedures and functions.

User-defined procedures requiring execution on the system database need to include the annotation @SystemProcedure or they will be classified as standard database procedures.

Create a procedure or function

Make sure you have read and followed the preparatory setup instructions in Setting up a plugin project.

The example discussed below is available as a repository on GitHub. To get started quickly you can fork the repository and work with the code as you follow along in the guide below.

First, decide what the procedure, function, or aggregation function should do, then write a test that proves that it does it right. Finally, write a procedure, function, or aggregation function that passes the test.

Create integration tests

You can use the test dependencies Neo4j Harness and JUnit to write integration tests for your procedure, function, or aggregation function. The tests should start a Neo4j server, load the procedure, and execute queries against it.

The following is an example using Neo4j Harness and JUnit 5 for testing a procedure that returns relationship types found in the graph:

package example;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.neo4j.driver.Driver;
import org.neo4j.driver.GraphDatabase;
import org.neo4j.driver.Record;
import org.neo4j.driver.Result;
import org.neo4j.driver.Session;
import org.neo4j.driver.Value;
import org.neo4j.harness.Neo4j;
import org.neo4j.harness.Neo4jBuilders;

import static org.assertj.core.api.Assertions.assertThat;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class GetRelationshipTypesTests {

    private Driver driver;
    private Neo4j embeddedDatabaseServer;

    @BeforeAll
    void initializeNeo4j() {
        this.embeddedDatabaseServer = Neo4jBuilders.newInProcessBuilder()
                .withDisabledServer()
                .withProcedure(GetRelationshipTypes.class)
                .build();

        this.driver = GraphDatabase.driver(embeddedDatabaseServer.boltURI());
    }

    @AfterAll
    void closeDriver(){
        this.driver.close();
        this.embeddedDatabaseServer.close();
    }

    @AfterEach
    void cleanDb(){
        try(Session session = driver.session()) {
            session.run("MATCH (n) DETACH DELETE n");
        }
    }

    /**
     * We should be getting the correct values when there is only one type in each direction
     */
    @Test
    public void shouldReturnTheTypesWhenThereIsOneEachWay() {
        final String expectedIncoming = "INCOMING";
        final String expectedOutgoing = "OUTGOING";

        // In a try-block, to make sure we close the session after the test
        try(Session session = driver.session()) {

            //Create our data in the database.
            session.run(String.format("CREATE (:Person)-[:%s]->(:Movie {id:1})-[:%s]->(:Person)", expectedIncoming, expectedOutgoing));

            //Execute our procedure against it.
            Record record = session.run("MATCH (u:Movie {id:1}) CALL example.getRelationshipTypes(u) YIELD outgoing, incoming RETURN outgoing, incoming").single();

            //Get the incoming / outgoing relationships from the result
            assertThat(record.get("incoming").asList(Value::asString)).containsOnly(expectedIncoming);
            assertThat(record.get("outgoing").asList(Value::asString)).containsOnly(expectedOutgoing);
        }
    }
}

The following is an example that uses Neo4j Harness for testing a user-defined function that joins a list of strings:

package example;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.neo4j.driver.Driver;
import org.neo4j.driver.GraphDatabase;
import org.neo4j.driver.Session;
import org.neo4j.harness.Neo4j;
import org.neo4j.harness.Neo4jBuilders;

import static org.assertj.core.api.Assertions.assertThat;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class JoinTest {

    private Neo4j embeddedDatabaseServer;

    @BeforeAll
    void initializeNeo4j() {
        this.embeddedDatabaseServer = Neo4jBuilders.newInProcessBuilder()
                .withDisabledServer()
                .withFunction(Join.class)
                .build();
    }

    @AfterAll
    void closeNeo4j() {
        this.embeddedDatabaseServer.close();
    }

    @Test
    void joinsStrings() {
        // This is in a try-block, to make sure we close the driver after the test
        try(Driver driver = GraphDatabase.driver(embeddedDatabaseServer.boltURI());
            Session session = driver.session()) {

            // When
            String result = session.run( "RETURN example.join(['Hello', 'World']) AS result").single().get("result").asString();

            // Then
            assertThat( result).isEqualTo(( "Hello,World" ));
        }
    }
}

The following is an example that uses Neo4j Harness and JUnit 5 for testing a user-defined aggregation function that finds the longest string:

package example;

import org.junit.Rule;
import org.junit.Test;
import org.neo4j.driver.v1.*;
import org.neo4j.harness.junit.Neo4jRule;

import static org.hamcrest.core.IsEqual.equalTo;
import static org.junit.Assert.assertThat;

public class LongestStringTest
{
    // This rule starts a Neo4j instance
    @Rule
    public Neo4jRule neo4j = new Neo4jRule()

            // This is the function to test
            .withAggregationFunction( LongestString.class );

    @Test
    public void shouldAllowIndexingAndFindingANode() throws Throwable
    {
        // This is in a try-block, to make sure you close the driver after the test
        try( Driver driver = GraphDatabase.driver( neo4j.boltURI() , Config.build().withEncryptionLevel( Config.EncryptionLevel.NONE ).toConfig() ) )
        {
            // Given
            Session session = driver.session();

            // When
            String result = session.run( "UNWIND ["abc", "abcd", "ab"] AS string RETURN example.longestString(string) AS result").single().get("result").asString();

            // Then
            assertThat( result, equalTo( "abcd" ) );
        }
    }
}

Define your procedure, function, or aggregation function

With the test in place, write a procedure, function, or aggregation function that fulfills the expectations of the test. The full example is available in the Neo4j Procedure Template repository.

See Values and types for details on values and types.

Particular things to note:

  • All procedures are annotated @Procedure.

  • The procedure annotation can take three optional arguments: name, mode, and eager.

    • name is used to specify a different name for the procedure than the default generated, which is class.path.nameOfMethod. If mode is specified, name must be specified as well.

    • name is not allowed in a reserved namespace, and having a name without a namespace is deprecated behavior.

    • If a procedure is registered with the same name as a built-in procedure in a deprecated namespace, the built-in procedure is shadowed.

    • mode is used to declare the types of interactions that the procedure performs. A procedure fails if it attempts to execute database operations that violate its mode. The default mode is READ. The following modes are available:

      • READ — This procedure only performs read operations against the graph.

      • WRITE — This procedure performs read and write operations against the graph.

      • SCHEMA — This procedure performs operations against the schema, i.e. create and drop indexes and constraints. A procedure with this mode can read graph data, but not write.

      • DBMS — This procedure performs system operations such as user management and query management. A procedure with this mode is not able to read or write graph data.

    • eager is a boolean setting defaulting to false. If it is set to true, the Cypher planner plans an extra eager operation before and after calling the procedure. This is useful in cases where the procedure makes changes to the database in a way that could interact with the operations preceding or following the procedure. For example:

      MATCH (n)
      WHERE n.key = 'value'
      WITH n
      CALL example.deleteNeighbours(n, 'FOLLOWS')

      This query can delete some of the nodes that are matched by the Cypher query, and the n.key lookup will fail. Marking this procedure as eager prevents this from causing an error in Cypher code. However, it is still possible for the procedure to interfere with itself by trying to read entities it has previously deleted. It is the responsibility of the procedure author to handle that case.

  • The context of the procedure, which is the same as each resource that the procedure wants to use, is annotated @Context.

  • All functions are annotated with @UserFunction.

  • The function name must be namespaced and is not allowed in reserved namespaces.

  • If a function is registered with the same name as a built-in function in a deprecated namespace, the built-in function is shadowed.

  • All functions are annotated with @UserAggregationFunction.

  • The annotated function must return an instance of an aggregator class.

  • An aggregator class contains one method annotated with @UserAggregationUpdate and one method annotated with @UserAggregationResult. The method annotated with @UserAggregationUpdate is called multiple times and enables the class to aggregate data. When the aggregation is done, the method annotated with @UserAggregationResult is called once and the result of the aggregation is returned.

  • The aggregation function name must be namespaced and is not allowed in reserved namespaces.

  • If a user-defined aggregation function is registered with the same name as a built-in function in a deprecated namespace, the built-in function is shadowed.

For more details, see the Neo4j Javadocs for org.neo4j.procedure.UserAggregationFunction.

The correct way to signal an error from within an aggregation function is to throw RuntimeException.

Write your user-defined procedure, function, and aggregation function

The following examples show how to write a user-defined procedure, function, and aggregation function.

The following is an example of a user-defined procedure that returns the relationship types going in and out of a given node:

package example;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

import org.neo4j.graphdb.Direction;
import org.neo4j.graphdb.Node;
import org.neo4j.graphdb.Relationship;
import org.neo4j.logging.Log;
import org.neo4j.procedure.Context;
import org.neo4j.procedure.Description;
import org.neo4j.procedure.Name;
import org.neo4j.procedure.Procedure;

public class GetRelationshipTypes {
    // This gives us a log instance that outputs messages to the
    // standard log, normally found under `data/log/console.log`
    @Context
    public Log log;

    /**
     * This procedure takes a Node and gets the relationships going in and out of it
     *
     * @param node  The node to get the relationships for
     * @return  A RelationshipTypes instance with the relations (incoming and outgoing) for a given node.
     */
    @Procedure(name = "example.getRelationshipTypes")
    @Description("Get the different relationships going in and out of a node.")
    public Stream<RelationshipTypes> getRelationshipTypes(@Name("node") Node node) {
        List<String> outgoing = new ArrayList<>();
        node.getRelationships(Direction.OUTGOING).iterator()
            .forEachRemaining(rel -> AddDistinct(outgoing, rel));

        List<String> incoming = new ArrayList<>();
        node.getRelationships(Direction.INCOMING).iterator()
                .forEachRemaining(rel -> AddDistinct(incoming, rel));

        return Stream.of(new RelationshipTypes(incoming, outgoing));
    }

The following is an example of a user-defined function that joins a list of strings with a given delimiter:

package example;

import java.util.List;

import org.neo4j.procedure.Description;
import org.neo4j.procedure.Name;
import org.neo4j.procedure.UserFunction;

/**
 * This is an example how you can create a simple user-defined function for Neo4j.
 */
public class Join {

    @UserFunction
    @Description("example.join(['s1','s2',...], delimiter) - join the given strings with the given delimiter.")
    public String join(
            @Name("strings") List<String> strings,
            @Name(value = "delimiter", defaultValue = ",") String delimiter) {
        if (strings == null || delimiter == null) {
            return null;
        }
        return String.join(delimiter, strings);
    }
}

The following is an example of a user-defined aggregation function that finds the longest string:

package example;

import org.neo4j.procedure.Description;
import org.neo4j.procedure.Name;
import org.neo4j.procedure.UserAggregationFunction;
import org.neo4j.procedure.UserAggregationResult;
import org.neo4j.procedure.UserAggregationUpdate;

public class LongestString
{
    @UserAggregationFunction
    @Description( "org.neo4j.function.example.longestString(string) - aggregates the longest string found" )
    public LongStringAggregator longestString()
    {
        return new LongStringAggregator();
    }

    public static class LongStringAggregator
    {
        private int longest;
        private String longestString;

        @UserAggregationUpdate
        public void findLongest(
                @Name( "string" ) String string )
        {
            if ( string != null && string.length() > longest)
            {
                longest = string.length();
                longestString = string;
            }
        }

        @UserAggregationResult
        public String result()
        {
            return longestString;
        }
    }
}

Injectable resources

When writing procedures, functions, or aggregation functions, some resources can be injected into the procedure from the database. To inject these, use the @Context annotation. The classes that can be injected are:

  • Log

  • TerminationGuard

  • GraphDatabaseService

  • Transaction

All of the above classes are considered safe and future-proof and do not compromise the security of the database. Several unsupported (restricted) classes can also be injected and can be changed with little or no notice. Procedure, functions, and aggregation functions written to use these restricted APIs are not loaded by default, and you need to use the dbms.security.procedures.unrestricted to load them. Read more about this config setting in Operations Manual → Securing extensions.

Call user-defined procedures or functions

You can call user-defined procedures and functions from Cypher queries in the same way as built-in procedures and functions.

To call a user-defined procedure, use a Cypher CALL clause. The procedure name must be fully qualified, so a procedure named findDenseNodes defined in the package org.neo4j.examples could be called using:

CALL org.neo4j.examples.findDenseNodes(1000)

CALL may be the only clause within a Cypher statement or may be combined with other clauses. Arguments can be supplied directly within the query or taken from the associated parameter set. For full details, see the documentation in Cypher Manual → CALL procedure.

User-defined functions are called in the same way as any other Cypher function. The function name must be fully qualified, so a function named join defined in the package org.neo4j.examples could be called using:

MATCH (p: Person) WHERE p.age = 36
RETURN org.neo4j.examples.join(collect(p.names))

User-defined aggregation functions are called in the same way as any other Cypher aggregation function. The function name must be fully qualified, so a function named longestString defined in the package org.neo4j.examples could be called using:

MATCH (p: Person) WHERE p.age = 36
RETURN org.neo4j.examples.longestString(p.name)

Reload procedures and functions

You can use the built-in procedure dbms.reloadProcedures() to reload the user supplied procedure or function JAR files from the plugins directory into a running Neo4j, without restarting it. This procedure is not supported on Windows.

For more information about plugins, see Operations Manual → Configure plugins.

Syntax

The syntax for the dbms.reloadProcedures() procedure is as follows:

Table 1. Details

Syntax

dbms.reloadProcedures(namespaces = * :: STRING) :: (name :: STRING, type :: STRING)

Description

Reload procedures from disk.

Input arguments

Name

Type

Description

namespaces

STRING

Optional. A glob pattern selecting which namespaces to reload. Defaults to '*', meaning everything. Example: 'com.example.*'.

Returned arguments

Name

Type

Description

name

STRING

The fully qualified name of each entry point that was reloaded.

type

STRING

The type of each entry point that was reloaded. Possible values are: PROCEDURE, FUNCTION or AGGREGATION FUNCTION.

Mode

DBMS

A successful call returns one row for every entry point it brought back into service. An empty result means nothing matched the pattern.

The namespace argument genuinely limits the operation. It is not advisory. A plugin whose namespace does not match the pattern is left untouched, even if its JAR file changed on disk. Three namespaces apoc.*, gds.*, and aura.* are not reloadable by default.

Example

Keep in mind that in a cluster deployment, you need to deploy the JAR files and run the dbms.reloadProcedures() procedure on each cluster member. Otherwise, the cluster members will be out of sync, leading to unexpected behavior.

The reload process is isolated so that the operation only affects new transactions. Already running transactions continue to execute with a snapshot of the available procedures or functions at their respective initialization.

You can use the following example to test the dbms.reloadProcedures() procedure to reload a user-defined procedure or function without restarting the server, as well as withdraw a procedure or function.

The example assumes you have a Neo4j server running with the example-v1.jar plugin in the plugins directory, which contains a procedure com.example.someProcedure().

  1. Confirm that reload procedure is available by running the following query:

    SHOW PROCEDURES YIELD name, description, admin
    WHERE name = 'dbms.reloadProcedures'
    RETURN name, description, admin;
    +------------------------------------------------------------------+
    | name                    | description                    | admin |
    +------------------------------------------------------------------+
    | "dbms.reloadProcedures" | "Reload procedures from disk." | TRUE  |
    +------------------------------------------------------------------+
    
    1 row
    ready to start consuming query after 111 ms, results consumed after another 8 ms
  2. Replace the example-v1.jar with example-v2.jar while the server keeps running. The new JAR file contains the com.example.someProcedure as well as some new procedures and functions:

    rm  plugins/example-v1.jar
    cp  example-v2.jar plugins/
  3. Reload that namespace:

    CALL dbms.reloadProcedures('com.example.*');
    name                                       type
    "com.example.someProcedure"                "PROCEDURE"
    "com.example.newProcedure"                 "PROCEDURE"
    "com.example.newFunction"                  "FUNCTION"
    "com.example.newAggregationFunction"       "AGGREGATION FUNCTION"

    The procedure returns all the entry points in that namespace, including the new ones.

Similarly, you can withdraw a procedure or function by removing its JAR file and reloading the namespace.

Confirm that no restart happened

You can check that the server did not restart by checking the server start time and uptime. The server start time can be read from Cypher and should be unchanged across a reload.

CALL dbms.queryJmx('java.lang:type=Runtime') YIELD attributes
RETURN datetime({epochMillis: attributes.StartTime.value}) AS serverStarted,
       duration({milliseconds: attributes.Uptime.value})   AS uptime;

Troubleshooting

A reload builds an intermediate registry and only swaps it into service if the whole load succeeds. If any JAR in the directory is invalid, the reload fails and the previously loaded plugins keep serving, unchanged. When a corrupt file is in the plugins directory, the reload will return 52N24 and the plugin that was already in service will continue to answer correctly. Removing the invalid file and reloading again restores normal operation with no restart.

Logging

Every reload writes a matched pair of entries to debug.log, giving operators an audit trail of what was reloaded and when. For example:

INFO c.n.d.p.ReloadProcedure "Reloading procedure namespace `com.example.*`."
INFO c.n.d.p.ReloadProcedure "Reloaded 2 procedures, 1 functions, and 1 aggregation functions."

Query cache invalidation

Cached Cypher plans that reference a changed signature are discarded automatically, so a client does not continue to run a stale plan against the old plugin. For example, if a procedure signature changes, the following log entry is written to debug.log:

INFO o.n.c.i.c.CypherQueryCaches "Discarded stale query from the query cache after 5 seconds.
Reason: Procedure or function signature have been modified. Query id: 79."

Reserved and deprecated procedure namespaces

Note that deprecated procedure and function namespaces will be moved to reserved in the next major Cypher version. For more information about Neo4j and Cypher versioning, see Operations manual → Introduction.

The following table shows the reserved and deprecated procedure namespaces:

Reserved Deprecated in Cypher 25 since Neo4j 2025.11

cdc.*

*

date.*

abac.*

datetime.*

builtin.*

db.*

coll.*

dbms.*

math.*

duration.*

plugin.*

graph.*

point.*

internal.*

stored.*

localdatetime.*

string.*

localtime.*

vector.*

time.*

tx.*

unsupported.*

The following table shows the reserved and deprecated function namespaces:

Reserved Deprecated in Cypher 25 since Neo4j 2025.11

*

abac.*

date

builtin.*

date.realtime

cdc.*

date.statement

coll.*

date.transaction

date.*

date.truncate

datetime.*

datetime

db.*

datetime.fromepoch

dbms.*

datetime.fromepochmillis

duration.*

datetime.realtime

graph.*

datetime.statement

internal.*

datetime.transaction

localdatetime.*

datetime.truncate

localtime.*

db.nameFromElementId

math.*

duration

plugin.*

duration.between

point.*

duration.inDays

stored.*

duration.inMonths

string.*

duration.inSeconds

time.*

graph.byElementId

tx.*

graph.byName

unsupported.*

graph.names

vector.*

graph.propertiesByName

localdatetime

localdatetime.realtime

localdatetime.statement

localdatetime.transaction

localdatetime.truncate

localtime

localtime.realtime

localtime.statement

localtime.transaction

localtime.truncate

point.distance

point.withinBBox

time

time.realtime

time.statement

time.transaction

time.truncate

vector.similarity.cosine

vector.similarity.euclidean