Using async drivers for Oracle and SQL Server

Oracle is reachable with oracledb in async mode and SQL Server with aioodbc — but check which is native and which wraps a synchronous driver in a thread pool, because a thread-backed driver gives the async API without async concurrency, and its limit is the thread pool rather than pool_size. This guide belongs to selecting async drivers for SQLite, MySQL and Postgres.

Quick Answer

Both backends have an async path, and both need their connection details spelled out more explicitly than PostgreSQL does.

Native async, or threads underneath Four tiles. asyncpg for PostgreSQL is a native asyncio protocol implementation. oracledb in async mode is native asyncio in its thin mode. aioodbc for SQL Server wraps pyodbc in a thread pool, so concurrency is bounded by threads rather than by the event loop. aiosqlite wraps the standard library sqlite3 module in a thread, for the same reason. asyncpg PostgreSQL native asyncio oracledb (async) Oracle native in thin mode aioodbc SQL Server pyodbc in a thread pool aiosqlite SQLite sqlite3 in a thread A thread-backed driver still gives you the async API; it does not give you async concurrency.

Oracle, with oracledb in async mode:

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

# The async URL token for oracledb was added in SQLAlchemy 2.0.25; check your version's
# documentation, and pin the version you tested against.
engine = create_async_engine(
    "oracle+oracledb_async://shop:secret@db.internal:1521/?service_name=ORCLPDB1",
    pool_size=5,
    max_overflow=5,
    pool_pre_ping=True,
)
Session = async_sessionmaker(engine, expire_on_commit=False)

SQL Server, with aioodbc:

import urllib.parse

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

odbc = urllib.parse.quote_plus(
    "DRIVER={ODBC Driver 18 for SQL Server};"
    "SERVER=db.internal,1433;DATABASE=shop;"
    "UID=shop;PWD=secret;"
    "Encrypt=yes;TrustServerCertificate=no;"
)
engine = create_async_engine(
    f"mssql+aioodbc:///?odbc_connect={odbc}",
    pool_size=5,
    max_overflow=5,
    pool_pre_ping=True,
)
Session = async_sessionmaker(engine, expire_on_commit=False)

The odbc_connect form is worth using for SQL Server rather than assembling a URL: the ODBC driver name contains spaces and braces, and quoting the whole connection string once avoids a class of parsing problems. Above the engine, AsyncSession and every query construct behave exactly as they do on PostgreSQL.

Execution Context & Async Workflow Integration

The most important distinction between async drivers is not which database they talk to but whether they are natively asynchronous.

Where the concurrency limit lives Left: a native asyncio driver issues twenty queries on twenty connections and waits on twenty sockets in one thread, so the limit is the pool and the database. Right: a thread-backed driver hands each query to a worker thread, so the limit is the size of the thread pool, and queries beyond it queue in Python before reaching the database. native (asyncpg, oracledb thin) 20 queries, 20 sockets one thread, the event loop waits limited by pool and database scales with pool_size thread-backed (aioodbc, aiosqlite) 20 queries, N worker threads queries queue in Python limited by the thread pool pool_size above it does nothing Sizing a pool above the thread pool of a thread-backed driver buys nothing but idle connections.

asyncpg implements the PostgreSQL wire protocol on asyncio directly: twenty concurrent queries are twenty sockets that one thread waits on. oracledb in its thin mode does the same for Oracle. Both scale with the connection pool and the database.

aioodbc and aiosqlite are wrappers: they run a synchronous driver — pyodbc and the standard library's sqlite3 — in a thread pool, and present an asyncio interface over it. The API is identical and the concurrency model is not. The number of queries that can be in flight is the number of worker threads, and connections beyond that sit idle while requests queue inside Python. Raising pool_size above the thread pool buys nothing.

Three consequences follow for a thread-backed driver. Size the thread pool deliberately rather than relying on the default, which is derived from the CPU count and is usually unrelated to how many database connections you want. Keep pool_size at or below the thread pool. And measure under concurrency, because a sequential benchmark cannot show the ceiling — the shape to look for is throughput that stops improving as concurrency rises, described in benchmarking asyncpg vs psycopg throughput.

Maturity is the other factor worth weighing honestly. SQLAlchemy's asyncio support was built around asyncpg, and that path carries by far the most production traffic. The Oracle and SQL Server async dialects are newer and less travelled, which does not make them unusable — it makes pinning versions, running your own integration tests, and reading the changelog before upgrading more important than usual.

Everything above the engine is unchanged. AsyncSession, loader options, begin_nested(), stream() and the pool all work the same way, which is the point of the abstraction: the driver choice does not reach the application's query code, only its configuration and its concurrency ceiling.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:oracle.oracledb_asyncThe SQLAlchemy version predates async oracledb support.Upgrade SQLAlchemy, and pin the version you tested.
InterfaceError: ('IM002', '[IM002] ... Data source name not found')The ODBC driver named in the connection string is not installed.Install the Microsoft ODBC driver and match its exact name.
DatabaseError: DPY-3010: connections to this database server version are not supportedoracledb thin mode against an unsupported server version.Use thick mode with Oracle Client libraries, or upgrade the server.
Throughput flat as concurrency risesA thread-backed driver saturating its thread pool.Raise the executor size; keep pool_size at or below it.
IntegrityError on a NOT NULL column that was given ""Oracle treats the empty string as NULL.Store a sentinel, or allow NULL and normalise in the application.
Generated keys missing after a bulk insert on SQL ServerSQL Server returns them through OUTPUT, with its own restrictions.Insert in smaller batches, or supply keys client-side.
Identifiers not found after switching backendsOracle folds unquoted names to upper case.Use lower-case names consistently and let SQLAlchemy quote them.
Four differences that reach the code Four tiles. Oracle treats an empty string as NULL, so a NOT NULL column cannot hold one. Oracle has no native boolean and SQLAlchemy emulates it with a number and a check constraint. SQL Server returns generated keys through OUTPUT rather than RETURNING, which changes how bulk inserts recover them. And identifier case differs: Oracle folds unquoted names to upper case, PostgreSQL to lower. Oracle: '' IS NULL an empty string is NULL NOT NULL rejects it Oracle: no boolean emulated as NUMBER(1) plus a CHECK SQL Server: OUTPUT not RETURNING affects bulk key retrieval identifier case folding Oracle → UPPER PostgreSQL → lower SQLAlchemy hides most of this; these four leak into application behaviour anyway.

The empty-string behaviour is the Oracle difference most likely to change application code, because it is silent. Oracle has no distinction between '' and NULL for VARCHAR2, so a form field submitted empty becomes NULL, a NOT NULL constraint rejects it, and a query for = '' never matches. Application-level normalisation is the only reliable answer:

from sqlalchemy.orm import validates


class Customer(Base):
    __tablename__ = "customers"
    # ...

    @validates("middle_name", "company")
    def _empty_to_none(self, key: str, value: str | None) -> str | None:
        return value or None       # store NULL rather than '' consistently

Doing it explicitly means the same code behaves identically on PostgreSQL, where '' and NULL are different values — which matters if the application supports both backends, and matters even more if it is being migrated from one to the other.

Feature availability is the other thing to check before designing around it. RETURNING exists on modern Oracle and on SQL Server as OUTPUT, with restrictions — SQL Server refuses OUTPUT on a table with certain triggers, for instance — and SQLAlchemy's bulk-insert optimisation adapts accordingly. Upserts differ too: Oracle has MERGE, SQL Server has MERGE with well-documented concurrency caveats, and neither maps onto PostgreSQL's ON CONFLICT as used in writing Postgres ON CONFLICT DO UPDATE upserts.

Advanced: The to_thread Alternative, and Pooling Choices

When the async driver for a backend is thread-backed anyway, running the synchronous driver explicitly through asyncio.to_thread() is a legitimate and sometimes better option: the driver is the mature one everyone tests, and you own the thread pool.

Three questions to settle first Three questions. Is the driver native or thread-backed, because that decides whether async buys concurrency or only syntax. How mature is the SQLAlchemy dialect in async mode, since these paths see far less production traffic than asyncpg. And does the feature set the application relies on exist, including RETURNING, upserts, JSON operators and streaming. native or thread-backed? a thread wrapper gives the API without the concurrency — size the thread pool, not just the connection pool how exercised is this dialect in async mode? asyncpg is the reference implementation; other async paths are newer and less travelled do the features you depend on exist? RETURNING, upserts, JSON operators, server-side cursors and streaming all vary by backend
import asyncio
from concurrent.futures import ThreadPoolExecutor

from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker

from shop.models import Order

sync_engine = create_engine(
    "mssql+pyodbc:///?odbc_connect=" + odbc, pool_size=8, pool_pre_ping=True
)
SyncSession = sessionmaker(sync_engine, expire_on_commit=False)
executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix="db")


def _open_orders(customer_id: int) -> list[dict]:
    with SyncSession() as session:
        rows = session.execute(
            select(Order.id, Order.total_cents)
            .where(Order.customer_id == customer_id, Order.status == "open")
        ).mappings().all()
        return [dict(row) for row in rows]


async def open_orders(customer_id: int) -> list[dict]:
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(executor, _open_orders, customer_id)

Three properties make this attractive for a secondary backend. The executor size and the pool size are matched explicitly, so the ceiling is visible in the code. The function returns plain data rather than ORM objects, which avoids passing session-bound objects across the boundary. And the driver is the one with a decade of production behind it.

The cost is that you now have two programming models in one codebase, and every call site has to remember the boundary. That is manageable when the backend is secondary — a legacy Oracle system a service reads from — and unpleasant as the primary data store. The general form of this bridge, in both directions, is covered in calling async SQLAlchemy from synchronous code.

Pooling also deserves a decision per backend. Oracle's client libraries have their own session pool, and oracledb can use it; layering SQLAlchemy's pool on top of a driver pool means two pools with two idle timeouts and two sets of statistics. Pick one — SQLAlchemy's, usually, so that pool_pre_ping, pool_recycle and the checkout metrics behave the way they do everywhere else in the application.

For SQL Server, the ODBC driver maintains connection pooling at the driver manager level on some platforms, which can interact with SQLAlchemy's pool in the same way. Disabling ODBC pooling and keeping SQLAlchemy's is the arrangement that is easiest to reason about, and the one whose behaviour matches the rest of this section's guidance on configuring async engines and connection pools.

Testing Against a Second Backend

A codebase that supports two backends needs its tests to run against both, because the differences are exactly the things no amount of reading catches. Three practices make that affordable.

A third option worth considering Left: keep the mature synchronous driver and call it through asyncio.to_thread, which is explicit about the thread boundary and uses the driver everyone has tested. Right: adopt the async driver, which is cleaner at the call site and, for a thread-backed driver, is doing much the same thing internally with less control over the thread pool. sync driver + to_thread the mature, well-tested driver the thread boundary is explicit you size the executor a synchronous Session inside async driver AsyncSession everywhere one programming model thread pool hidden (if any) newer code paths For a thread-backed driver the two are closer than they look; the difference is who owns the threads.

Parametrise the engine fixture over the backends you support, and mark the tests that cannot run everywhere:

import os

import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine

BACKENDS = {
    "postgresql": os.environ.get("TEST_POSTGRES_URL"),
    "oracle": os.environ.get("TEST_ORACLE_URL"),
    "mssql": os.environ.get("TEST_MSSQL_URL"),
}


@pytest_asyncio.fixture(params=[name for name, url in BACKENDS.items() if url])
async def engine(request):
    engine = create_async_engine(BACKENDS[request.param])
    engine.info["backend"] = request.param
    yield engine
    await engine.dispose()


postgres_only = pytest.mark.skipif(
    os.environ.get("TEST_BACKEND") != "postgresql",
    reason="uses PostgreSQL-specific features",
)

Skipping by feature rather than by backend name is better still: a test marked "needs JSONB" documents why it does not run, and keeps working when another backend gains the feature.

Write the portability tests that matter. Four behaviours are worth asserting per backend, because they are where the differences show up: an empty string round-trips as expected; a generated primary key is available after flush; a NULL sorts where the application assumes; and a Decimal keeps its scale. Each is three lines and each has caught a real difference.

Keep backend-specific SQL behind a boundary. Anything using JSONB, ON CONFLICT, FILTER or window frames needs a portable fallback or a clear statement that the feature is PostgreSQL-only. The cleanest arrangement is a small module per backend implementing one interface, so the choice is visible in one place and the rest of the application is backend-agnostic.

Finally, be realistic about what supporting two backends costs. Every feature is designed twice, tested twice, and constrained by the less capable of the two — which for PostgreSQL-specific features like JSONB and row-level security means giving up a lot. Supporting a second backend is worth it when a customer contract requires it, and a large ongoing tax when it was adopted "in case we need it". The single-backend alternative, with the capabilities that come from committing, runs through the rest of this site's guidance on advanced query patterns.

Frequently Asked Questions

Can SQLAlchemy use Oracle asynchronously?

Yes, through oracledb in async mode, from SQLAlchemy 2.0.25 onwards. Check the documentation for your version's URL token and pin the version you tested against.

Is aioodbc really asynchronous?

It presents an asyncio API by running pyodbc in a thread pool, so the concurrency limit is the thread pool rather than the event loop. Size the executor along with the connection pool.

Should I use to_thread with a synchronous driver instead?

It is a reasonable choice for a secondary backend whose async driver is thread-backed anyway: you get the mature driver and explicit control of the thread pool, at the cost of two programming models in one codebase.

What Oracle behaviour most often breaks application code?

The empty string being identical to NULL. A NOT NULL column rejects "", and = '' never matches. Normalise empty strings to None in the application so behaviour is the same on every backend.