python-oracledb: The Complete Guide to Python's Oracle Database Driver

Oracle Database powers a staggering share of enterprise infrastructure, and Python has been connecting to it for over 25 years. If you work in an environment that runs Oracle, you need a driver. The driver you need is python-oracledb.

This article traces the driver's lineage from its creation in the late 1990s, explains the architectural shift that made it "thin" by default, walks through real connection patterns and query techniques, covers the PEP that governs its API, examines asyncio support and connection pooling, and addresses the security and performance patterns that matter in production.

The Origin Story: From cx_Oracle to python-oracledb

The history of Python's Oracle connectivity begins with Anthony Tuininga. Tuininga grew up in a small town in British Columbia, Canada, before moving to Edmonton, Alberta to attend university, where he earned a computer engineering degree. In the late 1990s, he was maintaining a C++ library for managing Oracle database objects. After discovering Python, he experimented with wrapping Oracle's C API through Python's extension mechanism. In a 2017 PyDev of the Week interview on the Mouse Vs Python blog, Tuininga described how he rewrote his C++ tools using Python and cx_Oracle in just a few days and found that, despite Python being interpreted, the results were actually faster than his compiled code — largely because Python's higher-level data manipulation techniques required far less effort to implement (Source: Mouse Vs Python, "PyDev of the Week: Anthony Tuininga," December 11, 2017).

That project became cx_Oracle, with development beginning around 1998 and the first formal release appearing in the early 2000s. The "cx" prefix came from Computronix (Canada) Ltd., the Edmonton-based software development company where Tuininga worked at the time — a fact preserved in the cx_Oracle license file, which carries a Computronix copyright from 2001 through 2007 (Source: cx_Oracle License, cx-oracle.readthedocs.io). Over the following two decades, cx_Oracle became the de facto standard for accessing Oracle databases from Python, evolving through major releases that added support for Python 3, Unicode, LOBs, Advanced Queuing, and the full scope of Oracle's feature set. Tuininga remains listed as the package author on PyPI to this day and continues contributing to the successor driver.

In May 2022, Oracle released python-oracledb 1.0, a ground-up reimagining of the driver. The first release landed on 26th May 2022, and as Christopher Jones, Oracle's Database Product Manager for language drivers, wrote in a retrospective blog post, the Thin mode immediately resonated with users because it eliminated the Oracle Client library dependency entirely (Source: Christopher Jones, "A Celebration of Python and Oracle Database," Medium, June 2024). In a follow-up post in June 2025, Jones reported that daily python-oracledb downloads had doubled since the 3.0 release alone (Source: Christopher Jones, "A huge year of growth for python-oracledb," Medium, June 2025). By March 2026, the driver has reached version 3.4.2 (released January 28, 2026), with a 4.0 beta available in the documentation.

Deprecation Notice

cx_Oracle is now officially obsolete and should not be used for new development. The rename to python-oracledb was not cosmetic — while python-oracledb maintains API compatibility (you can literally import oracledb as cx_Oracle and many programs will run unchanged), cx_Oracle no longer receives updates.

Thin Mode vs. Thick Mode: The Architecture That Changed Everything

The single most important concept in python-oracledb is the distinction between Thin mode and Thick mode. Understanding this distinction determines how you install, deploy, and troubleshoot the driver.

Thin mode (the default) connects directly to Oracle Database 12.1 or later using Oracle's network protocol, implemented entirely in Python and Cython. No Oracle Client libraries are required. Installation is just:

pip install oracledb

That is it. The wheel packages are typically 2–4 MB depending on platform. It works on Linux, macOS, Windows, ARM, Alpine Linux, IoT devices, and cloud environments where installing Oracle Instant Client would be impractical or impossible. Python versions 3.9 through 3.14 are supported.

Thick mode is enabled at runtime by loading Oracle Client libraries (typically Oracle Instant Client). It provides access to some advanced Oracle features that the Thin mode driver has not yet reimplemented natively:

import oracledb

# Enable Thick mode (must be called before any connections)
oracledb.init_oracle_client(lib_dir="/opt/oracle/instantclient_21_3")

The feature gap between Thin and Thick modes has been narrowing steadily with each release. Database object binding and fetching was added to Thin mode in version 1.2 (November 2022), and scrollable cursors followed in a later release. Version 3.0 (released March 2025) added Advanced Queuing with RAW and object payloads, Transaction Guard, centralized configuration providers, and cloud native authentication to Thin mode. Subsequent releases continued closing the gap: version 3.1 added AQ support for JSON payloads and bulk operations in Thin mode, and version 3.2 brought AQ recipient list support and Instance Principal authentication. By version 3.4.2 (January 28, 2026), the list of Thick-only features has shrunk to a handful of edge cases. Connectivity to Oracle Database versions older than 19c is now officially deprecated (Source: python-oracledb 3.4.0 Release Notes, python-oracledb.readthedocs.io).

Pro Tip

For new projects, start with Thin mode. Switch to Thick mode only if you hit a specific feature that requires it. The development team is actively closing the gap with each release.

PEP 249: The Standard That Governs the API

Python-oracledb conforms to the Python Database API Specification v2.0, defined in PEP 249. Written by Marc-Andre Lemburg and finalized in 1999, PEP 249 defines the standard interface that all Python database drivers should implement: how connections are created, how cursors work, what exceptions look like, and how transactions behave.

PEP 249 is what makes it possible to switch between database drivers (sqlite3, psycopg2, mysql-connector, oracledb) without rewriting your entire data access layer. The core pattern is consistent across all compliant drivers:

# PEP 249 pattern — works across all compliant drivers
import oracledb  # or sqlite3, or psycopg2, etc.

connection = oracledb.connect(user="hr", password="welcome1", dsn="localhost/FREEPDB1")
cursor = connection.cursor()
cursor.execute("SELECT employee_id, first_name FROM employees WHERE department_id = :dept", [10])
for row in cursor:
    print(row)
cursor.close()
connection.close()

Python-oracledb goes well beyond PEP 249's minimum requirements, adding extensive Oracle-specific extensions: connection pooling, Advanced Queuing, SODA (Simple Oracle Document Access) for JSON documents, LOB streaming, array DML operations, implicit results, scrollable cursors, database change notifications, and more.

PEP 249 also defines the exception hierarchy that python-oracledb follows: InterfaceError, DatabaseError, DataError, OperationalError, IntegrityError, ProgrammingError, and NotSupportedError, all subclasses of the base Error class.

Note

One notable PEP 249 compliance change from cx_Oracle: python-oracledb requires keyword parameters in calls to connect(), create_pool(), and related functions (with the sole exception of single-argument DSN strings). This was a deliberate move to align with the specification.

Connecting to Oracle Database: Real Patterns

Basic Standalone Connection

import oracledb

# Using keyword parameters (PEP 249 compliant)
connection = oracledb.connect(
    user="hr",
    password="welcome1",
    dsn="localhost:1521/FREEPDB1"
)

print(f"Connected to Oracle {connection.version}")

# Using a context manager (recommended)
with oracledb.connect(user="hr", password="welcome1",
                       dsn="localhost:1521/FREEPDB1") as connection:
    with connection.cursor() as cursor:
        cursor.execute("SELECT sysdate FROM dual")
        result = cursor.fetchone()
        print(f"Database time: {result[0]}")

Connection Strings and Easy Connect

The dsn parameter accepts several formats:

# Easy Connect syntax
dsn = "dbhost.example.com:1521/orclpdb1"

# Easy Connect Plus (with additional parameters)
dsn = "tcps://dbhost.example.com:2484/orclpdb1?wallet_location=/path/to/wallet"

# Full connect descriptor
dsn = """(DESCRIPTION=
            (ADDRESS=(PROTOCOL=TCP)(HOST=dbhost.example.com)(PORT=1521))
            (CONNECT_DATA=(SERVICE_NAME=orclpdb1)))"""

# TNS alias (requires tnsnames.ora file)
dsn = "mydb"

Connecting to Oracle Autonomous Database (Cloud)

For Oracle Cloud, you typically connect using a wallet:

connection = oracledb.connect(
    user="admin",
    password="your_password",
    dsn="adb_tpurgent",
    config_dir="/path/to/wallet",
    wallet_location="/path/to/wallet",
    wallet_password="wallet_password"
)

Connection Pooling: Essential for Production

Standalone connections are fine for scripts and one-off tasks. For web applications, APIs, and any system handling concurrent requests, you need a connection pool. Creating a new database connection is expensive (network round trips, authentication, session setup). A pool maintains a cache of ready-to-use connections.

import oracledb

# Create a pool at application startup
pool = oracledb.create_pool(
    user="hr",
    password="welcome1",
    dsn="localhost:1521/FREEPDB1",
    min=2,           # Minimum connections kept open
    max=10,          # Maximum connections allowed
    increment=1,     # How many to add when pool needs to grow
    getmode=oracledb.POOL_GETMODE_WAIT  # Wait if all connections are busy
)

def get_employee(emp_id):
    """Acquire a connection from the pool, use it, return it."""
    with pool.acquire() as connection:
        with connection.cursor() as cursor:
            cursor.execute(
                "SELECT first_name, last_name, salary FROM employees WHERE employee_id = :id",
                [emp_id]
            )
            return cursor.fetchone()

# Use the pool
print(get_employee(100))

# Close the pool at application shutdown
pool.close()

Version 3.0 introduced named connection pools and a pool cache, allowing you to register pools by name and retrieve them later without passing pool objects around (Source: Christopher Jones, "Simplifying Python applications by using named connection pools," Oracle Developers blog, Medium, May 2025):

# Create a named pool (no need to store the return value)
oracledb.create_pool(
    user="hr", password="welcome1", dsn="localhost:1521/FREEPDB1",
    min=2, max=10, pool_alias="hr_pool"
)

# Acquire a connection from the named pool anywhere in your application
with oracledb.connect(pool_alias="hr_pool") as connection:
    with connection.cursor() as cursor:
        cursor.execute("SELECT sysdate FROM dual")

# Or retrieve the pool object directly if needed
pool = oracledb.get_pool("hr_pool")
Note

For Oracle Database Resident Connection Pooling (DRCP), which shares a pool of server processes across multiple middle-tier application pools, add server_type="pooled" to your connection parameters.

Async Support: python-oracledb with asyncio

Version 2.0 (May 2024) added native asyncio support, making python-oracledb one of the few Oracle drivers in any language with built-in async capabilities. This is not a wrapper around synchronous calls; the Thin mode driver uses non-blocking I/O natively. As Christopher Jones noted, asyncio support was a hugely popular feature request (Source: Christopher Jones, "Python-oracledb 2.0 has asyncio support," Medium, May 2024). Asyncio is only available in Thin mode.

import asyncio
import oracledb

async def main():
    # Async standalone connection
    async with oracledb.connect_async(
        user="hr", password="welcome1", dsn="localhost:1521/FREEPDB1"
    ) as connection:
        async with connection.cursor() as cursor:
            await cursor.execute("SELECT employee_id, first_name FROM employees")
            async for row in cursor:
                print(row)

asyncio.run(main())

Async Connection Pooling

import asyncio
import oracledb

async def main():
    # Create an async pool
    pool = oracledb.create_pool_async(
        user="hr", password="welcome1", dsn="localhost:1521/FREEPDB1",
        min=2, max=10
    )

    # Acquire connections concurrently
    async def fetch_department(dept_id):
        async with pool.acquire() as conn:
            async with conn.cursor() as cursor:
                await cursor.execute(
                    "SELECT department_name FROM departments WHERE department_id = :id",
                    [dept_id]
                )
                row = await cursor.fetchone()
                return row[0] if row else None

    # Run multiple queries concurrently
    results = await asyncio.gather(
        fetch_department(10),
        fetch_department(20),
        fetch_department(30)
    )
    print(results)  # ['Administration', 'Marketing', 'Purchasing']

    await pool.close()

asyncio.run(main())

In version 2.1 (March 2024), asyncio support graduated from pre-release status to production-ready (Source: Christopher Jones, "python-oracledb 2.1 and node-oracledb 6.4 have been released," Medium, March 2024). Version 2.5 (November 2024) introduced the register_protocol() hook for extending connection string handling, including LDAP support. Version 3.0 (March 2025) added asyncio support for Advanced Queuing operations, and pipelining support was promoted from pre-release to production status.

Statement Pipelining: Reducing Round Trips

One of the more architecturally significant features introduced in version 2.4 (August 2024) and promoted to production status in 3.0 is Oracle Database Pipelining. As Christopher Jones wrote, pipelining lets the database server and the application work concurrently instead of waiting on each other for statements and results (Source: Christopher Jones, "Pipelined database operations with python-oracledb 2.4," Medium, August 2024). Pipelining allows your application to send multiple independent SQL statements to the database in a single batch. While the database processes the pipeline, your application can continue doing other work. The statements are executed sequentially by the database, but the key benefit is the reduction of network round trips.

This matters enormously in cloud architectures where the network hop between your application server and the database may add several milliseconds of latency per round trip. If your application executes 50 small SQL statements sequentially, that latency compounds. With pipelining, you send all 50 at once and collect the results when they are ready.

import asyncio
import oracledb

async def main():
    async with oracledb.connect_async(
        user="hr", password="welcome1", dsn="localhost:1521/FREEPDB1"
    ) as connection:

        pipeline = oracledb.create_pipeline()
        pipeline.add_execute("INSERT INTO log (msg) VALUES (:1)", ["step_1"])
        pipeline.add_execute("INSERT INTO log (msg) VALUES (:1)", ["step_2"])
        pipeline.add_fetchone("SELECT COUNT(*) FROM log")
        pipeline.add_commit()

        results = await connection.run_pipeline(pipeline)
        row_count = results[2].rows[0][0]
        print(f"Log entries: {row_count}")

asyncio.run(main())
Compatibility Note

True pipelining — where the database processes subsequent statements before the previous one completes — requires Oracle Database 23ai or later. On older databases, python-oracledb executes the operations sequentially as a convenience for code portability, but there is no round-trip reduction. Pipelining is only available in Thin mode with asyncio.

Bind Variables: Security and Performance

Never concatenate user input into SQL strings. This is the single most important security practice in database programming, and python-oracledb makes parameterized queries straightforward.

# DANGEROUS — vulnerable to SQL injection
cursor.execute(f"SELECT * FROM employees WHERE last_name = '{user_input}'")

# SAFE — uses bind variables
cursor.execute("SELECT * FROM employees WHERE last_name = :name", {"name": user_input})

Beyond security, bind variables improve performance. Oracle's statement cache can reuse execution plans for queries that differ only in their bind values. Without bind variables, every unique SQL string generates a new hard parse, consuming CPU and shared pool memory.

Bind by Position and by Name

# Bind by position (using a list or tuple)
cursor.execute("SELECT * FROM employees WHERE department_id = :1 AND salary > :2", [10, 5000])

# Bind by name (using a dictionary)
cursor.execute(
    "SELECT * FROM employees WHERE department_id = :dept AND salary > :min_salary",
    {"dept": 10, "min_salary": 5000}
)

Batch Operations with executemany()

For inserting or updating many rows, executemany() sends all the data in a single round trip:

data = [
    {"id": 1001, "name": "Alice", "salary": 65000},
    {"id": 1002, "name": "Bob", "salary": 72000},
    {"id": 1003, "name": "Carol", "salary": 58000},
]

cursor.executemany(
    "INSERT INTO employees (employee_id, first_name, salary) VALUES (:id, :name, :salary)",
    data
)
connection.commit()

This is dramatically faster than calling execute() in a loop. For very large datasets, version 3.3 added support for binding data frames directly to executemany() for even faster ingestion, using Oracle Database's "Array DML" feature to reduce round trips and avoid internal conversion to Python types:

# Version 3.3+: Bind a DataFrame directly to executemany()
import pandas
d = {"emp_id": [1001, 1002, 1003], "salary": [65000, 72000, 58000]}
df = pandas.DataFrame(data=d)

cursor.executemany(
    "INSERT INTO employees (employee_id, salary) VALUES (:1, :2)", df
)
connection.commit()

Fetching Data: From Basic to Data Frames

Standard Fetch Patterns

with connection.cursor() as cursor:
    cursor.execute("SELECT employee_id, first_name, salary FROM employees")

    # Fetch one row at a time
    row = cursor.fetchone()

    # Fetch N rows
    batch = cursor.fetchmany(numRows=100)

    # Fetch all remaining rows
    all_rows = cursor.fetchall()

    # Or iterate directly (memory efficient)
    cursor.execute("SELECT employee_id, first_name, salary FROM employees")
    for emp_id, name, salary in cursor:
        print(f"{name}: ${salary:,.2f}")

Prefetching and Array Size Tuning

The cursor.prefetchrows and cursor.arraysize attributes control how many rows are fetched from the database in each network round trip. Tuning these can dramatically improve query performance:

cursor = connection.cursor()
cursor.prefetchrows = 1000  # Rows buffered by Oracle Client/network layer
cursor.arraysize = 1000     # Rows Python fetches from the buffer at once

cursor.execute("SELECT * FROM large_table")
while True:
    rows = cursor.fetchmany()
    if not rows:
        break
    process(rows)

Data Frame Integration (Version 3.0+)

For data science and analytics workloads, python-oracledb 3.0 introduced direct data frame fetching using the Apache Arrow PyCapsule interface. In version 3.3, the class names were simplified from OracleDataFrame to DataFrame and OracleArrowArray to ArrowArray, and data frame support for executemany() was added (Source: python-oracledb 3.3 Release Notes, python-oracledb.readthedocs.io). Version 3.4.0 added Direct Path Load functionality with DataFrame support for efficient bulk loading:

import oracledb

with oracledb.connect(user="hr", password="welcome1",
                       dsn="localhost:1521/FREEPDB1") as connection:
    # Fetch as a DataFrame, then convert to pandas
    odf = connection.fetch_df_all(
        statement="SELECT * FROM employees",
        arraysize=1000
    )
    
    # Convert to pandas DataFrame via PyArrow (version 3.3+ syntax)
    import pyarrow
    df = pyarrow.table(odf).to_pandas()
    
    # Or use Polars, PyArrow, etc. — any library supporting the PyCapsule interface

This approach avoids the overhead of fetching rows as Python tuples and then manually constructing a DataFrame. The Arrow-based exchange format enables near-zero-copy transfer for compatible libraries.

Working with Oracle-Specific Features

PL/SQL Execution

# Call a stored procedure
cursor.callproc("update_salary", [employee_id, new_salary])

# Call a function
result = cursor.callfunc("get_department_name", str, [department_id])

# Execute an anonymous PL/SQL block
cursor.execute("""
    BEGIN
        INSERT INTO audit_log (action, timestamp) VALUES (:action, SYSTIMESTAMP);
        :row_count := SQL%ROWCOUNT;
    END;
""", {"action": "DATA_EXPORT", "row_count": cursor.var(int)})

JSON Document Storage (SODA)

Oracle Database's SODA API lets you treat Oracle as a document database:

# Get a SODA database handle
soda = connection.getSodaDatabase()

# Create (or open) a collection
collection = soda.createCollection("employees")

# Insert a document
doc = collection.insertOneAndGet({"name": "Diana", "role": "Engineer", "skills": ["Python", "SQL"]})
print(f"Document key: {doc.key}")

# Query documents
for doc in collection.find().filter({"role": "Engineer"}).getDocuments():
    print(doc.getContent())

VECTOR Data Type (Oracle Database 23ai)

Oracle Database 23ai introduced the VECTOR data type for AI and machine learning workloads. Python-oracledb supports both dense and sparse vectors:

import array

# Store an embedding vector
embedding = array.array("f", [0.1, 0.25, 0.003, 0.87, 0.56])
cursor.execute(
    "INSERT INTO documents (id, content, embedding) VALUES (:1, :2, :3)",
    [1, "Python guide", embedding]
)

# Perform a similarity search
cursor.execute("""
    SELECT id, content
    FROM documents
    ORDER BY VECTOR_DISTANCE(embedding, :query_vec, COSINE)
    FETCH FIRST 5 ROWS ONLY
""", {"query_vec": query_embedding})

Centralized Configuration Providers

Version 3.0 introduced Centralized Configuration Providers, an often-overlooked feature that addresses a persistent pain point in enterprise Oracle deployments: connection string sprawl. In large organizations, database connection details (hostnames, ports, service names, pool sizes, credential references) get scattered across dozens of configuration files, environment variables, and deployment scripts. When a database is migrated or a service name changes, every application must be updated individually.

Configuration providers solve this by storing connection information in a central location. Python-oracledb 3.0+ supports three provider types: local JSON files, Oracle Cloud Infrastructure (OCI) Object Storage, and Microsoft Azure App Configuration. Your application code references the provider, and the driver resolves the full connection details at runtime:

# File-based configuration provider
connection = oracledb.connect(
    dsn="config-file:///etc/oracle/db-config.json?key=production"
)

# OCI Object Storage provider
connection = oracledb.connect(
    dsn="config-ociobject://my-bucket/config.json?key=staging"
)

# Azure App Configuration provider
connection = oracledb.connect(
    dsn="config-azure://my-appconfig.azconfig.io?key=production"
)

The JSON configuration file itself can store the connection string, database credentials, pool sizing parameters, and python-oracledb-specific settings. This creates a single source of truth for connection configuration, which is particularly valuable in microservice architectures where dozens of services connect to the same set of databases.

Security Considerations

Credential Management

Never hardcode passwords. Python-oracledb supports several secure credential patterns, ranging from environment variables to fully tokenized authentication:

import os
import oracledb

# Environment variables
connection = oracledb.connect(
    user=os.environ["DB_USER"],
    password=os.environ["DB_PASSWORD"],
    dsn=os.environ["DB_DSN"]
)

# External authentication (no password needed)
connection = oracledb.connect(externalauth=True, dsn="localhost:1521/FREEPDB1")

# Token-based authentication for Oracle Cloud (version 1.1+)
connection = oracledb.connect(
    user="admin",
    dsn="adb_connection_string",
    access_token=get_oci_token()  # OCI IAM or Azure AD token
)

Cloud Native Authentication (Version 3.0+)

Version 3.0 introduced a plugin-based Cloud Native Authentication framework that integrates directly with Oracle Cloud Infrastructure (OCI) and Azure identity services. Instead of managing credentials manually, your application can authenticate using cloud-native identity mechanisms:

# OCI Cloud Native Authentication with IAM tokens
# Install: pip install oracledb[oci_auth]
import oracledb.plugins.oci_tokens

token_auth = {"auth_type": "ConfigFileAuthentication"}
connection = oracledb.connect(
    dsn="adb_connection_string",
    config_dir="/path/to/wallet",
    wallet_location="/path/to/wallet",
    wallet_password="wallet_password",
    extra_auth_params=token_auth
)

# Instance Principal authentication (version 3.2+)
# No credentials needed — the OCI compute instance's identity is used
import oracledb.plugins.oci_tokens

token_auth = {"auth_type": "InstancePrincipal"}
connection = oracledb.connect(
    dsn="adb_connection_string",
    extra_auth_params=token_auth
)

Instance Principal authentication, added in version 3.2, is particularly significant for cloud-deployed workloads. It eliminates credential management entirely for applications running on OCI compute instances by using the platform-issued digital certificate to obtain short-lived IAM tokens (Source: Vignan V., "No More Credentials: Connect to Oracle Autonomous Database Using Instance Principals in python-oracledb 3.2," Oracle Developers blog, Medium, June 2025). This removes the risk of credential leakage in containerized or serverless deployments.

Encrypted Connections

For connections over untrusted networks, use TLS:

# Easy Connect Plus with TLS
connection = oracledb.connect(
    user="hr",
    password="welcome1",
    dsn="tcps://dbhost.example.com:2484/orclpdb1"
)

In Thin mode, python-oracledb uses Python's ssl module for TLS, giving you access to the standard ssl.SSLContext configuration for certificate validation, cipher selection, and protocol version control.

Migrating from cx_Oracle

If you are maintaining code that still uses cx_Oracle, the migration is straightforward. For the simplest case:

# Step 1: Change the import
# Old:
import cx_Oracle
# New (drop-in compatibility):
import oracledb as cx_Oracle

For a complete modernization, you should also remove any init_oracle_client() call (which forces Thick mode), switch to keyword parameters in connect(), replace SessionPool() with create_pool(), and remove the now-obsolete encoding and nencoding parameters.

The cx_Oracle 8.3 codebase and the python-oracledb Thick mode codebase share the same underlying C library (ODPI-C), so behavioral compatibility in Thick mode is effectively identical. The python-oracledb documentation includes a thorough appendix on the migration.

Performance Tips for Production

Use connection pools. A pool eliminates the overhead of creating new connections for each request. For web applications, the pool should be created once at startup and shared across all request handlers. Oracle's Real-World Performance Group recommends using fixed-size pools where min and max are set to the same value. This avoids connection storms during load spikes, which can degrade database throughput (Source: python-oracledb documentation, "Guideline for Preventing Connection Storms").

Set prefetchrows and arraysize together. For queries that return many rows, increasing both cursor.prefetchrows and cursor.arraysize to 1000 or more can reduce the number of network round trips by orders of magnitude.

Use executemany() for batch inserts. A single executemany() call with 1000 rows is dramatically faster than 1000 individual execute() calls.

Fetch data frames for analytics. If your destination is pandas, Polars, or PyArrow, use Connection.fetch_df_all() or Connection.fetch_df_batches() instead of fetching rows as tuples and constructing the DataFrame yourself.

Enable statement caching. Python-oracledb caches prepared statements by default (the stmtcachesize parameter defaults to 20). For applications that execute the same set of queries repeatedly, increasing this value reduces hard parse overhead on the database.

Use Thin mode unless you need Thick. Thin mode avoids the overhead of loading and initializing Oracle Client libraries. It also eliminates a common source of deployment problems (mismatched library versions, missing dependencies on the deployment target).

Consider pipelining for high-latency links. If your application sends many small, independent statements to a remote database (common in cloud architectures), pipelining with asyncio can reduce cumulative latency by batching those round trips. This requires Oracle Database 23ai or later for true pipeline execution.

Tune the Session Data Unit (SDU) for large transfers. The SDU controls internal buffer sizes for network communication. In Thin mode, you can set it as a connection or pool parameter. The default is 8 KB, which may be suboptimal for bulk data transfers over slow networks. Benchmark your specific workload before increasing it, as larger values can increase memory use and may not help with small-payload workloads.

The Ecosystem: Frameworks and ORMs

Python-oracledb works with the major Python data access frameworks thanks to its PEP 249 compliance. The ecosystem integration accelerated significantly in 2025, with contributions from Oracle's own driver team landing in upstream projects.

SQLAlchemy has supported python-oracledb since version 2.0 via the oracle+oracledb dialect. SQLAlchemy 2.0.41 (May 2025) added native support for Oracle's VECTOR data type, contributed by Suraj Shaw from Oracle's driver development group (Source: Christopher Jones, "SQLAlchemy supports Oracle Database 23ai VECTORs," Oracle Developers blog, Medium, May 2025). Connection strings look like:

from sqlalchemy import create_engine

engine = create_engine("oracle+oracledb://hr:welcome1@localhost:1521/?service_name=FREEPDB1")

Django supports Oracle Database through its django.db.backends.oracle backend, which uses python-oracledb as the underlying driver. Django 5.2 added native connection pooling support for Oracle, contributed by Suraj Shaw from Oracle's driver team (Source: Sharad Chandran, "Boost your Application Performance with Oracle Connection Pooling in Django," Oracle Developers blog, Medium, December 2024; Django documentation, docs.djangoproject.com/en/6.0/ref/databases):

# Django settings.py — enable connection pooling (Django 5.2+)
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.oracle",
        "NAME": "FREEPDB1",
        "USER": "hr",
        "PASSWORD": "welcome1",
        "HOST": "localhost",
        "PORT": "1521",
        "OPTIONS": {
            "pool": {
                "min": 2,
                "max": 10,
            },
        },
    }
}

Pandas read_sql() accepts python-oracledb connections directly, and the newer data frame integration provides a faster alternative for large result sets. For analytics workloads, the Arrow-based exchange format means you can go from Oracle query to Pandas, Polars, or PyArrow DataFrame with near-zero-copy transfer.

LangChain integration with Oracle AI Vector Search is also available for AI applications that need retrieval-augmented generation (RAG) against Oracle-stored embeddings. The integration code lives in the langchain-oracle repository on GitHub.

Troubleshooting Decision Framework

When things go wrong with python-oracledb, the diagnostic path depends almost entirely on whether you are running in Thin or Thick mode. Here is a systematic approach that addresses the issues developers encounter and rarely find documented in a single place.

Connection failures in Thin mode almost always trace to network reachability, TLS configuration, or incorrect connect strings. Thin mode handles its own networking, so the standard diagnostic sequence is: verify the database host and port are reachable (telnet dbhost 1521), confirm the service name is correct, check that the cryptography Python package is installed and functional (it is automatically installed as a dependency of python-oracledb, but can fail to load on some platforms — error DPY-3016 indicates this problem), and verify that any firewall or proxy allows the Oracle Net protocol through. The error code prefix DPY- indicates a python-oracledb Thin mode error.

Connection failures in Thick mode are usually Oracle Client library issues. The error code prefix DPI- indicates an ODPI-C (Thick mode) error. Verify that init_oracle_client() is called before any connections, that the specified library directory exists and contains the correct architecture (64-bit Python needs 64-bit libraries), and that LD_LIBRARY_PATH (Linux) or PATH (Windows) includes the client library directory. On Linux, do not pass the lib_dir parameter to init_oracle_client() — the Oracle Client libraries must be in the system library search path before the Python process starts (Source: python-oracledb Installation Guide, python-oracledb.readthedocs.io).

Performance degradation over time typically indicates one of three issues: statement cache exhaustion (increase stmtcachesize if your application uses more distinct statements than the cache can hold), connection pool starvation (monitor pool.busy and pool.opened attributes; increase max if the pool is consistently fully utilized), or excessive hard parsing (verify you are using bind variables, not string concatenation).

"Which mode am I running in?" This is a question that causes more confusion than it should. Check programmatically:

# Check an individual connection's mode
print(f"Thin mode: {connection.thin}")  # True if this connection uses Thin mode

# Check if python-oracledb is running in Thin mode globally
print(f"Thin mode active: {oracledb.is_thin_mode()}")  # Returns True if Thin mode is in use

Key Takeaways

  1. Start with Thin mode: A single pip install oracledb is all you need. No Oracle Client libraries, no environment variable configuration, no version mismatch headaches. Thin mode connects to Oracle Database 12.1 and later.
  2. Always use bind variables: They prevent SQL injection and improve performance by enabling Oracle to reuse execution plans across queries that differ only in their parameter values.
  3. Use connection pools for concurrent workloads: Pools eliminate connection overhead and are essential for web applications and APIs. Named pools in version 3.0+ make them easier to manage across large codebases. For Django users, version 5.2+ supports native pool configuration.
  4. cx_Oracle is obsolete: Migration is straightforward. In the simplest case, a single import alias change is all that's needed. A full modernization takes a bit more effort but pays off immediately in easier deployments.
  5. Leverage pipelining for latency-sensitive workloads: If your application sends many small SQL statements and runs on Oracle 23ai or later, pipelining can significantly reduce cumulative network latency. It requires asyncio and Thin mode.
  6. Centralize your connection configuration: For organizations managing many applications against shared databases, centralized configuration providers eliminate connection string sprawl and reduce the risk of misconfiguration during database migrations.
  7. The driver is actively evolving: Native asyncio, Apache Arrow data frame integration, VECTOR support for AI workloads, cloud native authentication, pipelining, and steady Thin/Thick mode parity improvements make python-oracledb worth staying current on. Version 3.4.2 is the latest stable release (January 28, 2026), with a 4.0 beta available that drops support for Oracle Client libraries before 19c. Comprehensive documentation lives at python-oracledb.readthedocs.io.

Python-oracledb represents a genuine leap forward from cx_Oracle. The shift to Thin mode as the default eliminated what was historically the most painful aspect of Oracle development in Python: the dependency on Oracle Client libraries. The era of wrestling with Oracle Instant Client installations and environment variable configuration is over for the vast majority of use cases. With pipelining for latency-sensitive architectures, Arrow-based data frame integration for analytics workloads, VECTOR support for AI applications, and cloud native authentication for zero-credential deployments, python-oracledb is not just how Python talks to Oracle now — it is how Python talks to Oracle well.

back to articles