JDBC Internals¶
This document covers the internal mechanics of the cursor, type system, connection pool, and query cancellation. It's mainly useful if you're debugging an edge case or contributing to jdbc4py.
Cursor¶
Cursor in cursor.py wraps a JDBC PreparedStatement. The execution flow for execute():
prepareStatement(sql)
→ set _statement + _cancel_requested atomically under _lock
→ bind_parameters(statement, params)
→ set _executing = True under _lock ← only after binding succeeds
→ statement.execute()
→ set _executing = False in finally
→ check _cancel_requested outside try/except
→ getResultSet() + getMetaData() → description
A few invariants:
_executingisTrueonly during the actual JDBC call, not during parameter binding. This meanscancel()won't try to cancel a statement that hasn't started yet._statement,_cancel_requested, and_executingare all updated atomically inside_lock.QueryCancelledErroris raised outside thetry/exceptblock to avoidraise exc from excin tracebacks.
executemany()¶
Streams parameters in chunks of 1,000, calling addBatch() / executeBatch() per chunk. Cancellation is checked between chunks.
Fetch paths¶
There are two fetch paths, depending on whether jdbc4py-helper.jar is present:
Optimized path (with helper JAR): JdbcBatchReader.readOptimizedBatch(resultSet, size) returns column-major arrays. Python assembles row tuples from those arrays with null masks applied per column. This minimizes JNI crossings for bulk fetches.
Fallback path (without helper JAR): resultSet.next() → read_current_row() → one tuple per row. Used when the helper JAR is missing or returns something unexpected.
PreparedStatement Cache¶
Each Connection holds a cache of prepared statements, capped at 128 queries × 4 idle statements per query; when the query limit is reached it evicts the oldest-inserted bucket (FIFO). When execute() is called, it first tries _borrow_statement() from the cache. If the cached statement is stale (e.g. connection was recycled), it falls back to a fresh prepareStatement().
The cache is shared across PooledConnection checkout cycles — prepared statements survive returning the connection to the pool.
Type System¶
types.py handles both directions: Python values going into JDBC parameters, and JDBC result values coming back as Python objects.
Parameter binding¶
bind_parameters() inspects each value and calls the appropriate JDBC setter:
| Python type | JDBC setter |
|---|---|
None |
setNull |
bool |
setBoolean |
int |
setInt / setLong / setBigDecimal (by magnitude) |
float |
setDouble |
Decimal |
setBigDecimal |
str |
setString |
bytes |
setBytes |
date / datetime / time |
setObject with java.time types |
timedelta |
interval string, or PGInterval for PostgreSQL |
UUID |
setObject(java.util.UUID) |
dict |
JSON string (or PGobject for PostgreSQL jsonb) |
list / tuple |
setArray (PostgreSQL, incl. multi-dim) or Java list (ClickHouse); a '[..]' literal for a VECTOR parameter |
ipaddress / Range / YearMonthInterval |
their text form (also as array elements) |
For engines that need it (PostgreSQL, Oracle), binding uses engine-specific paths. For example, PostgreSQL interval parameters go through PGInterval because the PostgreSQL driver is strict about interval format.
Parameter type hints from getParameterMetaData() are cached in an LRU OrderedDict keyed by (sql_text, param_count, engine). For long SQL (over 256 chars), the key uses an 8-byte SHA-256 digest.
Result conversion¶
build_column_info() does one pass over ResultSetMetaData and returns per-column converter callables. No second metadata scan during fetch. Engine-specific type names (ClickHouse uint64, PostgreSQL hstore, Oracle intervals) are checked before falling back to JDBC type codes.
Notable conversions:
- Unsigned 32-bit (ClickHouse
uint32, MySQL/MariaDBINT UNSIGNED) — fetched asgetLong()because values above 2^31-1 overflow a Javaint - Unsigned 64-bit (ClickHouse
uint64, MySQL/MariaDBBIGINT UNSIGNED) — fetched asgetString()then parsed: values above 2^63-1 can't be decoded as a Javalong(ClickHouse wraps to a negative; MySQL throws), so the unsigned decimal string is parsed directly - VECTOR / embeddings (pgvector, Oracle 23ai, …) →
list[float]; PostgreSQLinet/cidr→ipaddressobjects - Oracle
NUMBERwithout precision — returnsDecimalin the row path; the Arrow helper infers integer vs decimal from actual values - BLOB/CLOB — read fully on access; the Java CLOB cap (default 64M chars, configurable via
-Djdbc4py.clob.max_chars) prevents OOM on large LOBs - Oracle
INTERVALYM→YearMonthInterval;INTERVALDS→timedelta - PostgreSQL hstore →
dict[str, str | None]
Arrow Integration¶
The Arrow path is what makes jdbc4py fast for analytical workloads. Arrow IPC moves a large result set from JVM heap to Python in a single buffer handoff, rather than one JNI call per cell.
JVM side (JdbcArrowReader.java):
- Iterates the
ResultSetin configurable row groups (controlled bybatch_size) - Each column gets a typed vector writer (int, double, utf8, timestamp, etc.)
- CLOB columns are capped at
jdbc4py.clob.max_charschars (default 64M) to bound heap usage - Writes an Arrow IPC stream to a byte buffer, then hands the bytes to Python
Python side (connection.py):
- Deserializes with
pa.ipc.open_stream()— near-zero-copy - Applies post-processing for columns that need Python-side conversion (Oracle intervals, money columns, etc.)
- Returns a
pyarrow.Tableor yieldsRecordBatchobjects for streaming
The JdbcArrowStreamer runs a Java daemon thread that prefetches Arrow batches into a ArrayBlockingQueue(2), so the database can keep streaming while Python is processing the previous batch.
Connection Pool Internals¶
ConnectionPool in pool.py is a bounded, thread-safe pool backed by queue.Queue.
State:
_idle: Queue[_Slot]— connections waiting to be checked out_count: int— total connections in existence (idle + checked out), protected by_count_lock_closed: bool— checked on every iteration of the checkout loop_eviction_stop: threading.Event— signals the eviction thread to stop
Checkout flow:
while True:
if closed: raise InterfaceError
try to pull an idle slot → validate it (age, idle time, isValid ping)
if count < max_connections: open a new connection
else: block on _idle.get(timeout=min(remaining, 5s))
↑ cap on poll interval so _closed is re-checked periodically
Eviction: A daemon thread wakes every max(1, min(max_idle/2, max_age/2, 30)) seconds, drains the idle queue, closes expired slots, re-queues survivors, and opens new connections if the pool has dropped below min_size.
PooledConnection: Wraps a physical connection. close() returns the slot to the idle queue. _discard() (called when a with block exits via exception) closes the connection without returning it to the pool.
A circuit breaker short-circuits connection attempts when the database is unreachable — instead of all max_connections threads hammering a dead DB simultaneously, only one probe attempt is allowed through per cooldown window. The cooldown doubles on each consecutive failure, up to 30 s.
Cancellation Mechanics¶
cursor.cancel() is designed to be safe from any thread:
- Acquires
_lock; checks that_statementis set and_executingisTrue - Sets
_cancel_requested = True - Calls
statement.cancel()— best-effort; failures are swallowed - The executing thread calls
_consume_cancel_request()afterstatement.execute()returns. For a result-producing statement it raisesQueryCancelledError. For a non-result statement (INSERT/UPDATE/DDL) that already completed, it does not raise — the write was applied, and reporting it as cancelled would be a lie; the cancel simply lost the race.
A cancel that arrives between parameter binding and statement.execute() (before _executing = True) is detected by _consume_cancel_request() after execute returns. The window is short but the edge case is handled.
AsyncQuery.close() cancels the query, waits for it to finish, then closes the cursor. If the query doesn't finish within the timeout, a daemon thread is scheduled to close the cursor once it eventually completes. At most one deferred-close thread is spawned per AsyncQuery instance, regardless of how many threads call close() concurrently.