Run your own transactions

When querying the database with executeQuery(), the driver automatically creates a transaction. A transaction is a unit of work that is either committed in its entirety or rolled back on failure. You can include multiple Cypher statements in a single query, as for example when using MATCH and CREATE in sequence to update the database, but you cannot have multiple queries and interleave some client-logic in between them.

For these more advanced use-cases, the driver provides functions to take full control over the transaction lifecycle. These are called managed transactions, and you can think of them as a way of unwrapping the flow of execute_query() and being able to specify its desired behavior in more places.

Create a session

Before running a transaction, you need to obtain a session. Sessions act as concrete query channels between the driver and the server, and ensure causal consistency is enforced.

Sessions are created with the method Driver.session(). It takes a single (optional) object parameter, with the property database allowing to specify the target database. For further parameters, see Session configuration.

session = driver.session({ database: 'neo4j' })

Session creation is a lightweight operation, so sessions can be created and destroyed without significant cost. Always close sessions when you are done with them.

Sessions are not thread safe: you can share the main Driver object across threads, but make sure each thread creates its own sessions.

Run a managed transaction

A transaction can contain any number of queries. As Neo4j is ACID compliant, queries within a transaction will either be executed as a whole or not at all: you cannot get a part of the transaction succeeding and another failing. Use transactions to group together related queries which work together to achieve a single logical database operation.

A managed transaction is created with the methods Session.executeRead() and Session.executeWrite(), depending on whether you want to retrieve data from the database or alter it. Both methods take a transaction function callback, which is responsible of actually carrying out the queries and processing the result.

Retrieve people whose name starts with Al.
let session = driver.session({ database: 'neo4j' })  (1)
try {
  let result = await session.executeRead(async tx => { (2)
    return await tx.run(`  (3)
      MATCH (p:Person) WHERE p.name STARTS WITH $filter
      RETURN p.name AS name ORDER BY name
      `, {filter: 'Al'}
    )
  })
  for(let record in result.records) {  (4)
    console.log(record.get('name'))
  }
  console.log(
    `The query \`${result.summary.query.text}\`` +
    `returned ${result.records.length} nodes.\n`
  )
} finally {
  session.close()
}
1 Create a session. A single session can be the container for multiple queries. Remember to close it when done.
2 The .executeRead() (or .executeWrite()) method is the entry point into a transaction.
3 Use the method Transaction.run() to run queries, providing a Cypher query and an object of query parameters. Each query run returns a Result object.
4 Process the result records and query summary.

Do not hardcode or concatenate parameters directly into the query. Use query parameters instead, both for performance and security reasons.

Transaction functions should never return the Result object directly. Instead, always process the result in some way; at minimum, cast it to list. Within a transaction function, a return statement results in the transaction being committed, while the transaction is automatically rolled back if an exception is raised.

The methods .executeRead() and .executeWrite() have replaced .readTransaction() and .writeTransaction(), which are deprecated in version 5.x and will be removed in version 6.0.
A transaction with multiple queries, client logic, and potential roll backs
const neo4j = require('neo4j-driver');

(async () => {
  const URI = '<URI for Neo4j database>'
  const USER = '<Username>'
  const PASSWORD = '<Password>'
  let driver, session
  let employeeThreshold = 10

  try {
    driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASSWORD))
    await driver.verifyConnectivity()
  } catch(err) {
    console.log(`-- Connection error --\n${err}\n-- Cause --\n${err.cause}`)
    await driver.close()
    return
  }

  session = driver.session({ database: 'neo4j' })
  for(let i=0; i<100; i++) {
    const name = `Neo-${i.toString()}`
    const orgId = await session.executeWrite(async tx => {
      let result, orgInfo

      // Create new Person node with given name, if not already existing
      await tx.run(`
        MERGE (p:Person {name: $name})
        RETURN p.name AS name
        `, { name: name }
      )

      // Obtain most recent organization ID and number of people linked to it
      result = await tx.run(`
        MATCH (o:Organization)
        RETURN o.id AS id, COUNT{(p:Person)-[r:WORKS_FOR]->(o)} AS employeesN
        ORDER BY o.createdDate DESC
        LIMIT 1
      `)
      if(result.records.length > 0) {
        orgInfo = result.records[0]
      }

      if(orgInfo != undefined && orgInfo['employeesN'] == 0) {
        throw new Error('Most recent organization is empty.')
        // Transaction will roll back -> not even Person is created!
      }

      // If org does not have too many employees, add this Person to that
      if(orgInfo != undefined && orgInfo['employeesN'] < employeeThreshold) {
        result = await tx.run(`
          MATCH (o:Organization {id: $orgId})
          MATCH (p:Person {name: $name})
          MERGE (p)-[r:WORKS_FOR]->(o)
          RETURN $orgId AS id
          `, { orgId: orgInfo['id'], name: name }
        )

      // Otherwise, create a new Organization and link Person to it
      } else {
        result = await tx.run(`
          MATCH (p:Person {name: $name})
          CREATE (o:Organization {id: randomuuid(), createdDate: datetime()})
          MERGE (p)-[r:WORKS_FOR]->(o)
          RETURN o.id AS id
          `, { name: name }
        )
      }

      // Return the Organization ID to which the new Person ends up in
      return result.records[0].get('id')
    })
    console.log(`User ${name} added to organization ${orgId}`)
  }
  await session.close()
  await driver.close()
})()

Should a transaction fail for a reason that the driver deems transient, it automatically retries to run the transaction function (with an exponentially increasing delay). For this reason, transaction functions should produce the same effect when run several times (idempotent), because you do not know upfront how many times they are going to be executed. In practice, this means that you should not edit nor rely on globals, for example. Note that although transaction functions might be executed multiple times, the queries inside it will always run only once.

A session can chain multiple transactions, but only one single transaction can be active within a session at any given time. This means that a query must be completed before the next one can run, and it is the reason why the previous examples all use the async/await syntax. To maintain multiple concurrent transactions, see how to run asynchronous queries.

Run an explicit transaction

You can achieve full control over transactions by manually beginning one with the method Session.beginTransaction(). You run queries inside an explicit transaction with the method Transaction.run(), as you do in transaction functions.

let session = driver.session({ database: 'neo4j' })
let transaction = await session.beginTransaction()

// use tx.run() to run queries
//     tx.commit() to commit the transaction
//     tx.rollback() to rollback the transaction

await transaction.commit()
await session.close()

An explicit transaction can be committed with Transaction.commit() or rolled back with Transaction.rollback(). If no explicit action is taken, the driver will automatically roll back the transaction at the end of its lifetime.

Explicit transactions are most useful for applications that need to distribute Cypher execution across multiple functions for the same transaction, or for applications that need to run multiple queries within a single transaction but without the automatic retries provided by managed transactions.

Example stub with an explicit transaction involving external APIs
const neo4j = require('neo4j-driver');
const URI = '<URI for Neo4j database>';
const USER = '<Username>';
const PASSWORD = '<Password>';

(async () => {

  try {
    driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASSWORD))
    await driver.verifyConnectivity()
  } catch(err) {
    console.log(`-- Connection error --\n${err}\n-- Cause --\n${err.cause}`)
    await driver.close()
    return
  }

  let customerId = await createCustomer(driver)
  let otherBankId = 42
  await transferToOtherBank(driver, customerId, otherBankId, 999)
  await driver.close()
})()

async function createCustomer(driver) {
  let { records } = await driver.executeQuery(`
      MERGE (c:Customer {id: randomUUID()})
      RETURN c.id AS id
    `, {},
    { database: 'neo4j' }
  )
  return records[0].get("id")
}

async function transferToOtherBank(driver, customerId, otherBankId, amount) {
  const session = driver.session({ database: 'neo4j' })
  const tx = await session.beginTransaction()
  try {
    if(! checkCustomerBalance(tx, customerId, amount))
      return

    try {
      decreaseCustomerBalance(tx, customerId, amount)
      await tx.commit()
    } catch (error) {
      requestInspection(customerId, otherBankId, amount, e)
      throw error  // roll back
    }

    await otherBankTransferApi(customerId, otherBankId, amount)
    // Now the money has been transferred => can't rollback anymore
    // (cannot rollback external services interactions)
  } finally {
    await session.close()
  }
}

async function checkCustomerBalance(tx, customerId, amount) {
  result = await tx.run(`
    MATCH (c:Customer {id: $id})
    RETURN c.balance >= $amount AS sufficient
    `, { id: customerId, amount: amount },
    { database: 'neo4j' }
  )
  return result.records[0].get('sufficient')
}

async function otherBankTransferApi(customerId, otherBankId, amount) {
  // make some API call to other bank
}

async function decreaseCustomerBalance(tx, customerId, amount) {
  await tx.run(`
    MATCH (c:Customer {id: $id})
    SET c.balance = c.balance - $amount
    `, { id: customerId, amount: amount }
  )
}

async function requestInspection(customerId, otherBankId, amount, error) {
  // manual cleanup required; log this or similar
  console.log('WARNING: transaction rolled back due to exception:')
  console.log(error)
}

Session configuration

When creating a session, you can provide an optional parameter of type SessionConfig to specify session configuration values.

Database selection

You should always specify the database explicitly with the database parameter, even on single-database instances. This allows the driver to work more efficiently, as it saves a network round-trip to the server to resolve the home database. If no database is given, the user’s home database set in the Neo4j instance settings is used.

const session = driver.session({
  database: 'neo4j'
})
Specifying the database through the configuration method is preferred over the USE Cypher clause. If the server runs on a cluster, queries with USE require server-side routing to be enabled. Queries may also take longer to execute as they may not reach the right cluster member at the first attempt, and need to be routed to one containing the requested database.

Request routing

In a cluster environment, all sessions are opened in write mode, routing them to the leader. You can change this by explicitly setting the defaultAccessMode parameter to either neo4j.session.READ or neo4j.session.WRITE. Note that .executeRead() and .executeWrite() automatically override the session’s default access mode.

const session = driver.session({
  database: 'neo4j',
  defaultAccessMode: neo4j.session.READ
})

Although executing a write query in read mode likely results in a runtime error, you should not rely on this for access control. The difference between the two modes is that read transactions are routed to any node of a cluster, whereas write ones are directed to the leader. In other words, there is no guarantee that a write query submitted in read mode will be rejected.

Similar remarks hold for the .executeRead() and .executeWrite() methods.

Run queries as a different user

You can execute a query through a different user with the configuration parameter auth. Switching user at the session level is cheaper than creating a new Driver object. Queries are then run within the security context of the given user (i.e., home database, permissions, etc.).
Session-scoped authentication requires a server version >= 5.8.

const session = driver.session({
    database: 'neo4j',
    auth: neo4j.auth.basic('somebodyElse', 'theirPassword')
})

The parameter impersonatedUser provides a similar functionality, and is available in driver/server versions >= 4.4. The difference is that you don’t need to know a user’s password to impersonate them, but the user under which the Driver was created needs to have the appropriate permissions.

const session = driver.session({
    database: 'neo4j',
    impersonatedUser: 'somebodyElse'
})

Transaction configuration

You can exert further control on transactions by providing a second optional parameter of type TransactionConfig to .executeRead(), .executeWrite(), and .beginTransaction(). You can specify:

  • A transaction timeout (in milliseconds). Transactions that run longer will be terminated by the server. The default value is set on the server side. The minimum value is one millisecond.

  • An object of metadata that gets attached to the transaction. These metadata get logged in the server query.log, and are visible in the output of the SHOW TRANSACTIONS YIELD * Cypher command. Use this to tag transactions.

let session = driver.session({ database: 'neo4j' })
const people_n = await session.executeRead(
  async tx => { return await tx.run('MATCH (a:Person) RETURN count(a)') },
  { timeout: 5000, metadata: {'app_name': 'people'} }  // TransactionConfig
)

Close sessions

Each connection pool has a finite number of sessions, so if you open sessions without ever closing them, your application could run out of them. It is thus important to always close sessions when you are done with them, so that they can be returned to the connection pool to be later reused. The best way is to wrap session usage in a try/finally block, calling session.close() in the finally clause.

let session = driver.session({database: 'neo4j'})
try {
  // use session to run queries
} finally {
  await session.close()
}

Glossary

LTS

A Long Term Support release is one guaranteed to be supported for a number of years. Neo4j 4.4 is LTS, and Neo4j 5 will also have an LTS version.

Aura

Aura is Neo4j’s fully managed cloud service. It comes with both free and paid plans.

Cypher

Cypher is Neo4j’s graph query language that lets you retrieve data from the database. It is like SQL, but for graphs.

APOC

Awesome Procedures On Cypher (APOC) is a library of (many) functions that can not be easily expressed in Cypher itself.

Bolt

Bolt is the protocol used for interaction between Neo4j instances and drivers. It listens on port 7687 by default.

ACID

Atomicity, Consistency, Isolation, Durability (ACID) are properties guaranteeing that database transactions are processed reliably. An ACID-compliant DBMS ensures that the data in the database remains accurate and consistent despite failures.

eventual consistency

A database is eventually consistent if it provides the guarantee that all cluster members will, at some point in time, store the latest version of the data.

causal consistency

A database is causally consistent if read and write queries are seen by every member of the cluster in the same order. This is stronger than eventual consistency.

NULL

The null marker is not a type but a placeholder for absence of value. For more information, see Cypher → Working with null.

transaction

A transaction is a unit of work that is either committed in its entirety or rolled back on failure. An example is a bank transfer: it involves multiple steps, but they must all succeed or be reverted, to avoid money being subtracted from one account but not added to the other.

backpressure

Backpressure is a force opposing the flow of data. It ensures that the client is not being overwhelmed by data faster than it can handle.

transaction function

A transaction function is a callback executed by an executeRead or executeWrite call. The driver automatically re-executes the callback in case of server failure.

Driver

A Driver object holds the details required to establish connections with a Neo4j database.