| title | Migrate from pyodbc to mssql-python |
|---|---|
| description | Guide for migrating existing Python applications from pyodbc to the mssql-python driver for Microsoft SQL. |
| author | dlevy-msft-sql |
| ms.author | dlevy |
| ms.date | 07/17/2026 |
| ms.service | sql |
| ms.subservice | connectivity |
| ms.topic | how-to |
| ai-usage | ai-assisted |
The mssql-python driver is Microsoft's first-party Python driver for Microsoft SQL. If you prefer a Microsoft-maintained driver option, it offers:
- No external ODBC driver dependency.
- Built-in connection pooling.
- Modern Python 3.10+ support.
- Native Microsoft Entra authentication.
| Feature | pyodbc | mssql-python |
|---|---|---|
| Parameter style | qmark (?) |
qmark (?) and pyformat (%(name)s) |
| ODBC driver required | Yes | No |
| Connection pooling | External | Built-in |
| Minimum Python | 3.6 | 3.10 |
callproc() |
Supported | Not implemented |
| Autocommit default | Off | Off |
The following steps cover the key changes to migrate a pyodbc application to mssql-python.
Replace the pyodbc import with mssql_python:
Before (pyodbc):
import pyodbcAfter (mssql-python):
import mssql_pythonRemove the DRIVER= keyword and update the authentication method:
Before (pyodbc, requires ODBC driver):
conn = pyodbc.connect(
"DRIVER={ODBC Driver 18 for SQL Server};"
"SERVER=localhost;"
"DATABASE=AdventureWorks2022;"
"Trusted_Connection=yes;"
)After (mssql-python, no driver needed, using Microsoft Entra auth):
conn = mssql_python.connect(
"Server=<server>.database.windows.net;"
"Database=<database>;"
"Authentication=ActiveDirectoryDefault;"
"Encrypt=yes;"
)The mssql-python driver supports both ? (qmark) and %(name)s (pyformat) parameter styles. Your existing ? queries work without changes:
Before (pyodbc):
cursor.execute("SELECT * FROM Production.Product WHERE ProductID = ? AND Color = ?", (1, "Red"))After (mssql-python, same query):
cursor.execute("SELECT * FROM Production.Product WHERE ProductID = ? AND Color = ?", (1, "Red"))Existing executemany calls with tuples and ? markers work without changes:
Before (pyodbc):
cursor.execute("CREATE TABLE #MigrateDemo (ID INT, Name NVARCHAR(50))")
data = [(1, "Alice"), (2, "Bob"), (3, "Carol")]
cursor.executemany("INSERT INTO #MigrateDemo (ID, Name) VALUES (?, ?)", data)After (mssql-python, same code):
cursor.execute("DROP TABLE IF EXISTS #MigrateDemo")
cursor.execute("CREATE TABLE #MigrateDemo (ID INT, Name NVARCHAR(50))")
data = [(1, "Alice"), (2, "Bob"), (3, "Carol")]
cursor.executemany("INSERT INTO #MigrateDemo (ID, Name) VALUES (?, ?)", data)The mssql-python driver doesn't implement callproc(). The following sections show how to use EXECUTE instead.
The pyodbc driver supports callproc(), but the mssql-python driver doesn't. Use EXECUTE instead:
Before (pyodbc):
cursor.callproc("dbo.uspGetEmployeeManagers", (5,))
results = cursor.fetchall()After (mssql-python):
cursor.execute(
"EXECUTE dbo.uspGetEmployeeManagers @BusinessEntityID = %(id)s",
{"id": 5}
)
results = cursor.fetchall()
print(f"Got {len(results)} rows")Use T-SQL variables to capture output values instead of relying on callproc() output parameters:
Before (pyodbc, using callproc):
params = (category_id, pyodbc.SQL_INTEGER)
cursor.callproc("dbo.GetProductCount", params)
count = params[1].valueAfter (mssql-python, using T-SQL variables):
cursor.execute(
"""
DECLARE @count INT;
SELECT @count = COUNT(*) FROM Production.Product
WHERE ProductSubcategoryID = %(cat_id)s;
SELECT @count AS ProductCount;
""",
{"cat_id": 1}
)
product_count = cursor.fetchval()
print(f"Product count: {product_count}")The following sections cover specific pyodbc features and their mssql-python equivalents.
| pyodbc keyword | mssql-python keyword | Notes |
|---|---|---|
DRIVER={...} |
Not needed | The ODBC driver is bundled internally. |
SERVER= |
Server= |
No behavior change. |
DATABASE= |
Database= |
No behavior change. |
Trusted_Connection= |
Trusted_Connection= |
No behavior change. |
UID= / PWD= |
UID= / PWD= |
No behavior change. |
Authentication= |
Authentication= |
Accepts the same values. |
The autocommit behavior is identical in both drivers:
pyodbc:
conn.autocommit = True
pyodbc.connect(connection_string, autocommit=True)mssql-python:
conn.autocommit = TrueTo speed up large INSERT batches, pyodbc users set fast_executemany = True. The mssql-python driver already optimizes executemany for parameterized batches, so moderate inserts need no special flag. For large data loads, prefer bulkcopy(), which streams rows over the bulk-copy protocol and is much faster than issuing individual INSERT statements. For the full workflow, see Use bulk copy.
pyodbc:
cursor.fast_executemany = True
cursor.executemany(query, data)After (mssql-python), moderate batches with executemany:
cursor.execute("DROP TABLE IF EXISTS #BulkTarget")
cursor.execute("CREATE TABLE #BulkTarget (ID INT, Name NVARCHAR(50))")
data = [(i, f"Item {i}") for i in range(100)]
cursor.executemany("INSERT INTO #BulkTarget (ID, Name) VALUES (?, ?)", data)
conn.commit()After (mssql-python), large loads with bulkcopy (preferred):
cursor.execute("IF OBJECT_ID('##BulkTarget') IS NOT NULL DROP TABLE ##BulkTarget")
cursor.execute("CREATE TABLE ##BulkTarget (ID INT, Name NVARCHAR(50))")
conn.commit() # Commit DDL before bulkcopy
data = [(i, f"Item {i}") for i in range(100)]
result = cursor.bulkcopy("##BulkTarget", data)
print(f"Bulk copied {result['rows_copied']} rows")
cursor.execute("DROP TABLE ##BulkTarget")
conn.commit()The mssql-python driver returns Row objects that support attribute access by default, without requiring a custom row factory:
pyodbc (custom row factory):
def namedtuple_row_factory(cursor):
from collections import namedtuple
columns = [col[0] for col in cursor.description]
Row = namedtuple("Row", columns)
return Rowmssql-python (attribute access by default):
cursor.execute("SELECT Name, ListPrice FROM Production.Product")
row = cursor.fetchone()
print(row.Name) # Attribute access works directly
print(row[0]) # Index access also worksThe mssql-python driver uses the same exception hierarchy as pyodbc, so most exception handlers require only a module name change.
The exception class names map directly between drivers:
pyodbc:
try:
cursor.execute(query)
except pyodbc.Error as e:
pass
except pyodbc.DatabaseError as e:
pass
except pyodbc.OperationalError as e:
passmssql-python:
try:
cursor.execute("SELECT TOP 1 * FROM Production.Product")
print(cursor.fetchone())
except mssql_python.Error as e:
pass
except mssql_python.DatabaseError as e:
pass
except mssql_python.OperationalError as e:
passBoth drivers expose error details through exception arguments:
pyodbc:
try:
cursor.execute(query)
except pyodbc.Error as e:
sqlstate = e.args[0]
message = e.args[1]mssql-python:
try:
cursor.execute("SELECT TOP 1 * FROM NonExistentTable_XYZ")
except mssql_python.Error as e:
# Error message contains SQLSTATE and details
print(str(e))The mssql-python driver includes connection pooling by default, so external pooling libraries are no longer required.
If you used external pooling with pyodbc, the mssql-python driver has it built in:
Before (pyodbc external pool):
from dbutils.pooled_db import PooledDB
pool = PooledDB(pyodbc, 5, driver="{ODBC Driver 18 for SQL Server}",
server="your_server", database="your_database",
uid="your_username", pwd="your_password")
conn = pool.connection()After (mssql-python built-in pooling):
conn = mssql_python.connect(connection_string)
conn.close()Override the default pool size and timeout with mssql_python.pooling():
import mssql_python
mssql_python.pooling()The following shows the same function written with pyodbc and then rewritten with mssql-python.
This version uses the pyodbc connection string with a DRIVER keyword:
import pyodbc
from datetime import date
def get_orders(customer_id: int, start_date: date):
conn = pyodbc.connect(
"DRIVER={ODBC Driver 18 for SQL Server};"
"SERVER=localhost;"
"DATABASE=AdventureWorks2022;"
"Trusted_Connection=yes;"
)
cursor = conn.cursor()
cursor.execute("""
SELECT SalesOrderID, OrderDate, TotalDue
FROM Sales.SalesOrderHeader
WHERE CustomerID = ? AND OrderDate >= ?
ORDER BY OrderDate DESC
""", (customer_id, start_date))
orders = []
for row in cursor:
orders.append({
"id": row.SalesOrderID,
"date": row.OrderDate,
"total": row.TotalDue
})
cursor.close()
conn.close()
return ordersThis version removes the DRIVER keyword. All queries, parameters, and row access patterns remain identical:
import mssql_python
from datetime import date
def get_orders(customer_id: int, start_date: date):
conn = mssql_python.connect(
"Server=localhost;"
"Database=AdventureWorks2022;"
"Trusted_Connection=yes;"
)
cursor = conn.cursor()
cursor.execute("""
SELECT SalesOrderID, OrderDate, TotalDue
FROM Sales.SalesOrderHeader
WHERE CustomerID = ? AND OrderDate >= ?
ORDER BY OrderDate DESC
""", (customer_id, start_date))
orders = []
for row in cursor:
orders.append({
"id": row.SalesOrderID,
"date": row.OrderDate,
"total": row.TotalDue
})
cursor.close()
conn.close()
return ordersThe only changes are the import statement and the connection string (no DRIVER keyword needed). Every query, parameter, fetch pattern, and row access stays identical.
Before completing the migration, run the same queries against both drivers and compare results to confirm equivalent behavior.
Use a comparison function that runs the same query against both drivers and asserts the results match:
import pyodbc
import mssql_python
def compare_results(pyodbc_conn_str: str, mssql_conn_str: str, query: str):
"""Compare results from both drivers."""
# pyodbc query
pyodbc_conn = pyodbc.connect(pyodbc_conn_str)
pyodbc_cursor = pyodbc_conn.cursor()
pyodbc_cursor.execute(query)
pyodbc_results = pyodbc_cursor.fetchall()
pyodbc_conn.close()
# mssql-python query
mssql_conn = mssql_python.connect(mssql_conn_str)
mssql_cursor = mssql_conn.cursor()
mssql_cursor.execute(query)
mssql_results = mssql_cursor.fetchall()
mssql_conn.close()
# Compare
assert len(pyodbc_results) == len(mssql_results)
for p_row, m_row in zip(pyodbc_results, mssql_results):
assert tuple(p_row) == tuple(m_row)
print(f"Results match: {len(pyodbc_results)} rows")- Update imports from
pyodbctomssql_python. - Remove
DRIVER=from connection strings. - Keep existing
?parameter queries (they work as-is). - Use
EXECUTEstatements for stored procedure calls. - Remove external connection pooling configuration.
- Update exception handling class names.
- Test all queries and stored procedures.
- Verify data type handling (especially decimals and dates).
- Remove ODBC driver from deployment requirements.