The Neo4j Java driver doesn’t actually manage sessions or transactions for you; it delegates that responsibility entirely to your application code.

Let’s see how this plays out with a simple example. Imagine you want to create a new user node and then immediately query for it.

import org.neo4j.driver.*;
import static org.neo4j.driver.Values.parameters;

public class UserManagement {

    public static void main(String[] args) {
        // Driver is the entry point to Neo4j
        Driver driver = GraphDatabase.driver("bolt://localhost:7687", AuthTokens.basic("neo4j", "password"));

        try (Session session = driver.session()) {
            // This is where YOU, the developer, explicitly start a transaction
            Transaction tx = session.beginTransaction();
            try {
                tx.run("CREATE (:User {name: $name})", parameters("name", "Alice"));
                Result result = tx.run("MATCH (u:User {name: $name}) RETURN u", parameters("name", "Alice"));
                while (result.hasNext()) {
                    Record record = result.next();
                    System.out.println("Found user: " + record.get("u").get("name").asString());
                }
                // If everything above succeeded, commit the transaction
                tx.commit();
                System.out.println("Transaction committed successfully.");
            } catch (Exception e) {
                // If anything failed, rollback the transaction
                tx.rollback();
                System.err.println("Transaction rolled back due to error: " + e.getMessage());
            }
        } // Session is automatically closed here by the try-with-resources
        driver.close();
    }
}

Here, GraphDatabase.driver() establishes a connection pool to your Neo4j instance. The driver.session() method obtains a session from this pool, but it’s a lightweight object. The real work, like executing queries and ensuring atomicity, happens when you explicitly call session.beginTransaction().

The Session object acts as a container for transactions. When you call beginTransaction(), you get a Transaction object. This Transaction object is your handle to the ongoing database operation. You can run multiple queries within it using tx.run().

Crucially, the Transaction object has commit() and rollback() methods. You are responsible for deciding when to commit() (if all operations were successful) or rollback() (if any operation failed). The try-catch block around the transaction logic is where this decision is typically made. If an exception occurs, the catch block executes, and tx.rollback() is called. If no exception occurs, the tx.commit() line is reached.

The Session itself is managed by a try-with-resources block (try (Session session = driver.session())). This ensures that the session is properly closed and returned to the driver’s connection pool when you’re done with it, regardless of whether the transaction succeeded or failed.

The driver doesn’t enforce ordering or guarantee that you’ll always commit or rollback. It’s entirely up to your application logic. If you forget to call commit or rollback and the Session is closed, the driver will implicitly roll back any pending transaction. This is a safety net, but relying on it is bad practice.

What most developers miss is that the Session can be configured to run in Transaction.Type.READ_ONLY or Transaction.Type.WRITE mode. By default, it’s WRITE. If you are only reading data, explicitly using session.readTransaction(tx -> ...) or session.beginTx(Transaction.Type.READ_ONLY) can provide performance benefits by allowing Neo4j to optimize read operations and potentially use more efficient query plans or caching. It also acts as a strong signal to other developers (and yourself) about the intent of the code.

If you forget to call tx.commit() and the session is closed, you’ll see an error like "Transaction was not committed or rolled back".

Want structured learning?

Take the full Neo4j course →