Chapter 13 · Application Drivers and Bolt: Sessions, Routing, Parameters, Result Streaming, and Connection Pools
Sessions, Transactions, Parameters, Result Cursors/Streams, Fetch Size, and Backpressure
Keep session and result ownership explicit so parameters, lazy streaming and backpressure remain correct under real API concurrency.
Learning outcomes
An AtlasMart product endpoint returns thousands of orders. If the service eagerly materializes everything, it may turn a modest database query into a large client-memory spike. If it leaks a cursor outside its transaction/session scope, the consumer may fail after the database resources have already been released. This lesson makes row streaming and transaction ownership explicit.
Use short-lived sessions and managed transactions without sharing a session concurrently.
Parameterize Cypher values and keep result ownership inside the transaction/session scope.
Explain lazy Result cursors, fetch size and
Bolt backpressure.
Distinguish eager execute_query() results from
streaming transaction results.
Measure time-to-first-record, total consumption time and client memory without claiming fetch size fixes server-side sort/aggregation memory.
The mandatory lab continues Neo4j Community
2026.07.1, database neo4j, explicit
CYPHER 25 where query-language version matters,
container atlasmart-neo4j, loopback Bolt
127.0.0.1:7687, authentication
neo4j/atlasmart-course-2026, and no
TLS on the loopback-only disposable instance. The current
official Python driver is neo4j 6.3.0 (Python
3.10–3.14), whose API supports Bolt 6.0–6.1, Bolt 5.0–5.8 and
Bolt 4.4. Neo4j 5.26.30 remains the LTS
comparison line.
This generation environment does not connect to the AtlasMart Neo4j container, so no handshake, routing-table, pool, TLS, bookmark, latency or retry output is fabricated. Each exercise gives commands and invariants to capture on your machine. True multi-member read/write routing requires an Enterprise cluster or Aura deployment; the mandatory Community path proves the driver/session/pool/stream semantics locally and labels cluster-only observations separately.
1. Driver, session, transaction and result have different lifetimes
| Object | Cost/concurrency contract | Recommended lifetime |
|---|---|---|
| Driver | expensive; owns pool; thread-safe | application/process lifetime |
| Session | lightweight; not thread-safe; at most one active transaction | one logical request/unit of work; always close |
| Managed transaction callback | may be retried; must not perform unsafe non-idempotent external side effects | one database unit of work |
| Result | lazy cursor backed by connection/transaction state until consumed | consume or transform before its owning scope closes |
2. Parameterize values; do not build Cypher with user strings
# WRONG: input becomes syntax, not dataquery = "MATCH (c:Customer {customerId:'" + customer_id + "'}) RETURN c"session.run(query)
result = session.run( """ CYPHER 25 MATCH (c:Customer {customerId:$customerId})-[:PLACED]->(o:Order) RETURN o.orderId AS orderId, o.status AS status ORDER BY o.orderId """, customerId=customer_id,)
Parameters keep data separate from syntax, support plan reuse and remove a major injection class. They do not authorize the caller: tenant/user scope must still be expressed in the graph model, query predicate and application authorization policy.
3. Eager convenience vs lazy streaming
| API shape | Result behavior | Use when |
|---|---|---|
driver.execute_query() |
By default converts the result to an
EagerResult and retrieves all records
|
bounded response sets or simple repository methods |
session.execute_read/write() + iterate
Result
|
records arrive lazily in batches | large results, early stop, bounded client memory |
session.run() auto-commit |
lazy result; transaction lifetime is tied to result consumption | single-statement special cases; understand commit timing explicitly |
def stream_order_ids(tx, customer_id): result = tx.run( """ CYPHER 25 MATCH (:Customer {customerId:$customerId})-[:PLACED]->(o:Order) RETURN o.orderId AS orderId ORDER BY o.orderedAt DESC, o.orderId """, customerId=customer_id, ) for record in result: yield record["orderId"]# Better for a web response: consume/transform while the session is alive.with store.driver.session(database="neo4j", fetch_size=250) as session: def collect(tx): return list(stream_order_ids(tx, "C-1001")) order_ids = session.execute_read(collect)
Do not return a generator that still depends on
tx/Result after
execute_read() has returned. Materialize the
bounded API payload inside the callback, or design an
application streaming abstraction whose database session
remains deliberately open for the consumer lifetime.
4. Fetch size creates client-side backpressure
The Python driver default session fetch_size is
1000. The server sends records in batches; as the
client consumes them, the driver requests more. Smaller values
can reduce buffered client records but increase Bolt round
trips. Larger values reduce round trips but can increase
buffered memory. There is no universal number.
from time import perf_counterimport tracemallocQUERY = """CYPHER 25UNWIND range(1, 20000) AS nRETURN n, n * n AS squared"""for fetch_size in (100, 1000, 5000): tracemalloc.start() t0 = perf_counter() first = None count = 0 with store.driver.session(database="neo4j", fetch_size=fetch_size) as session: result = session.run(QUERY) for record in result: if first is None: first = perf_counter() - t0 count += 1 _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() print(fetch_size, count, "first_s=", first, "total_s=", perf_counter()-t0, "peak_bytes=", peak)
Run multiple warm and cold trials and report distributions, not
one winning elapsed time. Also remember that
ORDER BY, aggregation and other operators may need
server memory before records can stream; client fetch size does
not rewrite the server plan.
5. Wrong approach: share one session across concurrent requests
SESSION = store.driver.session(database="neo4j") # WRONG shared mutable sessiondef request_a(): return SESSION.run("RETURN 1").single()def request_b(): return SESSION.run("RETURN 2").single()
Sessions are not thread-safe and only one transaction may be
active per session. Share the Driver, not the
Session. Each concurrent request/thread/task should obtain its
own short-lived session or use
Driver.execute_query().
6. AtlasMart result contract
CYPHER 25MERGE (c:Customer {customerId:'C-1001'})SET c.name='Mina Rahimi', c.tier='GOLD'MERGE (p:Product {productId:'P-1001'})SET p.name='Trail Camera', p.category='Cameras', p.price=129.90MERGE (o:Order {orderId:'O-5001'})SET o.status='PAID', o.orderedAt=datetime('2026-09-08T16:30:00Z')MERGE (c)-[:PLACED]->(o)MERGE (o)-[r:CONTAINS]->(p)SET r.quantity=1, r.unitPrice=129.90;
def customer_orders(driver, customer_id: str) -> list[dict]: def work(tx): result = tx.run( """ CYPHER 25 MATCH (:Customer {customerId:$customerId})-[:PLACED]->(o:Order) RETURN o.orderId AS orderId, o.status AS status, toString(o.orderedAt) AS orderedAt ORDER BY o.orderedAt DESC, o.orderId """, customerId=customer_id) return [record.data() for record in result] with driver.session(database="neo4j", fetch_size=250) as session: return session.execute_read(work)
The repository exports ordinary Python values, not a live Neo4j cursor. That makes ownership, testing and serialization explicit.
Production judgment
| Signal | Interpretation | Possible action |
|---|---|---|
| large client peak memory | too many/too-large records materialized | stream, paginate, reduce projection, cap API payload |
| many small Bolt pulls/high RTT cost | fetch size too small for network latency | measure larger fetch sizes |
| slow first row despite small fetch | server operator must materialize/work before producing rows |
inspect PROFILE; optimize query/model/index
|
| session concurrency warning/failure | session used across threads/tasks | create one session per concurrent unit |
| cursor used after scope closed | repository leaked driver-owned resource | consume/transform before return |
Check your understanding
-
Is
fetch_size=100always faster than 1000? -
Why can a query with
ORDER BYstill use large server memory with a small fetch size? - Which object is safe to share across threads: Driver or Session?
-
Why return dictionaries instead of a live
Resultfrom a repository? - Does parameterization replace authorization?
Review the answers
1. No. It trades memory/buffering against extra network round trips; measure representative records and latency.
2. The server may need to sort/materialize rows before streaming; fetch size primarily controls record transfer/buffering.
3. Driver. Sessions are lightweight but not thread-safe.
4. It makes resource ownership explicit and avoids a cursor outliving its session/transaction.
5. No. It protects query structure; authorization/tenant predicates are separate requirements.
Summary and next step
Streaming controls how results occupy connections and memory; the connection pool controls how concurrent units obtain those connections. Lesson 3 turns pool sizing and timeouts into measurable capacity decisions.
Authoritative references
- Current Neo4j versions — Current server and 5.26 LTS release snapshot.
- Neo4j Python Driver Manual — Official application-driver guide used by the mandatory lab.
- Python Driver 6.3 API — Current API, Bolt compatibility and lifecycle contract.
- Driver connection guide — Driver lifetime, connectivity checks and cluster routing.
- Advanced connection information — URI schemes, TLS, resolver and connection configuration.
- Transactions with the Python driver — Session/transaction lifecycle, managed retries and result streaming.
- Python driver performance recommendations — Lazy streaming, fetch size and read routing guidance.
- Bolt compatibility matrix — Neo4j DBMS and negotiated Bolt protocol versions.
- Python Result API — Cursor/result ownership and consumption.
- Back-pressure migration note — Bolt batch pulling and fetch-size semantics.