SQLAlchemy reaches Azure SQL Database through a dialect whose generated SQL differs from every other backend in one specific way, and almost every surprise here traces back to it. Every Python block below was verified on 2026-09-03 against SQLAlchemy 2.0.52 and pyodbc 5.3.0 and its output quoted verbatim; the version boundary below was measured the same day against 2.0.8, 2.0.9 and 2.0.10. Engine error numbers are from Microsoft Learn.
The dialect is named mssql and that name belongs only in the URL scheme. Everywhere else the
product is Azure SQL Database, and an agent that adopts the dialect name has begun giving advice
for a different product with different limits.
The engine URL
import os
from sqlalchemy import create_engine
from sqlalchemy.engine import URL
credential = { # read them, never inline them "username": os.environ["SQL_USER"], "password": os.environ["SQL_PASSWORD"], } url = URL.create( "mssql+pyodbc", host=os.environ["SQL_HOST"], # .database.windows.net port=1433, database=os.environ["SQL_DATABASE"], query={"driver": "ODBC Driver 18 for SQL Server"}, **credential, ) engine = create_engine(url, pool_pre_ping=True) print(url.render_as_string(hide_password=True))
`URL.create` escapes the characters that break a hand built URL; `render_as_string` logs one with
the password back as `***`.
Driver choice, retry and passwordless connections belong to other skills; see References.
## The OUTPUT clause, which is the whole skill
To learn a server generated key the dialect adds an OUTPUT clause to the INSERT. Do not take that
on trust, compile it:
```python
from sqlalchemy import Column, Integer, String, insert
from sqlalchemy.dialects import mssql
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): # columns shared by both models
id = Column(Integer, primary_key=True)
sku = Column(String(20))
class Order(Base):
__tablename__ = "orders"
class Audited(Base): # this one carries a trigger
__tablename__ = "audited_orders"
__table_args__ = {"implicit_returning": False} # so: no OUTPUT clause
d = mssql.dialect()
print(insert(Order).values(sku="x").compile(dialect=d))
print(insert(Audited).values(sku="x").compile(dialect=d))
print(insert(Audited).values(sku="x").returning(Audited.id).compile(dialect=d))
Three lines out, and every claim below is one:
INSERT INTO orders (sku) OUTPUT inserted.id VALUES (:sku)
INSERT INTO audited_orders (sku) VALUES (:sku)
INSERT INTO audited_orders (sku) OUTPUT inserted.id VALUES (:sku)
Azure SQL Database refuses the first when the target carries an enabled trigger:
Msg 334, Level 15, State 1
The target table 'dbo.audited_orders' of the DML statement cannot have any enabled triggers
if the statement contains an OUTPUT clause without INTO clause.
Four properties decide how this plays out, and each is where agents guess wrong.
- It is per table, and nothing detects it. The dialect has no trigger reflection and does not probe for one, so a codebase where 40 models work and one fails is the normal shape of the bug.
- The switch is on the table, not the engine, as
__table_args__above orimplicit_returning=Falsepassed toTablein Core. The key then comes back fromscope_identity(), one extra round trip that works on a triggered table. Set it per table, not everywhere as a precaution: it costs the batched insert path on tables that never needed it. - The engine level parameter is a trap.
create_engine(url, implicit_returning=True)and theFalseform are both accepted, both raiseSADeprecationWarning, and neither changes one character of the SQL above. - The flag does not cover an explicit
returning(). That is the third printed line: with the flag off, a hand writteninsert().returning(...)still compiles to OUTPUT without INTO, andupdate().returning(...)anddelete().returning(...)do the same.
Triggers cluster on auditing and history tables, exactly where an ORM meets an existing schema. Find them before mapping one, with the query under Check it worked.
fast_executemany, and what changed in 2.0
Same cause as the trigger error above: the generated returning clause. An ORM insert of a mapped class with a server generated key carries one, because that is the OUTPUT clause, and that is the case the flag does not reach, so by default it changes nothing for exactly the bulk inserts people set it for.
fast_executemany=True has existed since SQLAlchemy 1.3; what changed is what it does, and the
boundary is measurable with no database:
python3 -c "import sqlalchemy; from sqlalchemy.dialects import mssql; \
print(sqlalchemy.__version__, mssql.dialect().use_insertmanyvalues)"
That prints 2.0.8 True, 2.0.9 False, 2.0.10 True. SQLAlchemy 2.0 introduced
insertmanyvalues, a batched INSERT form that returns keys, and because it returns keys it took
precedence over the driver's array binding and the flag stopped having any effect. 2.0.9 gave that
effect back for multi-parameter INSERT statements carrying no returning clause, switching
insertmanyvalues off here entirely to do it; 2.0.10 restored it, so 2.0.10 is the floor.
So the flag bites only where the INSERT carries no returning clause: the rows already hold their
keys, or the table is declared implicit_returning=False, or the load goes through Core rather
than tracked ORM objects.
Documented costs make this a decision, not a default: the batch must fit in memory, the
parameter is honoured for the Microsoft ODBC driver only, and setinputsizes is not used
there, which is where the surprising type handling on large loads comes from.
Type mapping, before the first migration
from sqlalchemy import Column, MetaData, String, Table, Text, Unicode, UnicodeText
from sqlalchemy.dialects import mssql
from sqlalchemy.schema import CreateTable
t = Table("t", MetaData(),
Column("a", String(50)), Column("b", Unicode(50)),
Column("c", Text()), Column("d", UnicodeText()))
print(CreateTable(t).compile(dialect=mssql.dialect())) # not connected
print(CreateTable(t).compile(dialect=mssql.dialect(deprecate_large_types=True))) # connected
The second print is what the ORM creates:
a VARCHAR(50) NULL, b NVARCHAR(50) NULL, c VARCHAR(max) NULL, d NVARCHAR(max) NULL
String is not Unicode. A model that uses it for names, addresses or user text gets a
non-Unicode column, and the mismatch between it and the Unicode parameter Python binds is the
implicit conversion that stops an index being used. Prefer Unicode or NVARCHAR(n) explicitly.
That the two prints differ is the second trap. Offline, deprecate_large_types is None and c
and d compile to TEXT and NTEXT; on first connect the dialect sets it from the server version,
and Azure SQL Database is always past the threshold. Compiling offline is the normal way to inspect
DDL and wrong for exactly this pair, so never report a TEXT or NTEXT read out of a compiled
statement as what will be created.
One default worth pinning: Sequence("s") with no start compiles to a bare
CREATE SEQUENCE s, and Microsoft Learn gives an ascending sequence's default start as the minimum
value of its type, -9223372036854775808 for the default bigint. Pass start=1, and prefer an
identity column for surrogate keys.
Alembic
Alembic drives the same dialect, so all the above applies to the DDL it emits. Read that DDL before it runs, which is the point of offline mode:
alembic revision --autogenerate -m "add audited_orders"
alembic upgrade head --sql > migration.sql # writes SQL, runs nothing
grep -nE 'TEXT|NTEXT|VARCHAR\(' migration.sql # the type mapping above, before it ships
alembic upgrade head
Three operations need arguments here that they need nowhere else, and autogenerate will not add them:
alter_columnrequiresexisting_typewhen changing nullability.drop_indexrequirestable_name.drop_columnon a column with a DEFAULT, CHECK or single foreign key constraint takesmssql_drop_default,mssql_drop_checkormssql_drop_foreign_keyto drop the unnamed constraint first.
These come from the Alembic documentation, not from a run: Alembic was not installed on the machine
that produced the numbers above. Whether a migration is safe to run at all,
expand and contract, and never migrating on startup, are schema-migrations-safely.
Check it worked
Three checks, in the order they catch things. Which tables carry an enabled trigger is first, because nothing in Python will tell you:
sqlcmd -S <server-name>.database.windows.net,1433 -d <database> -U <user> -C -b -m-1 -Q \
"SELECT OBJECT_SCHEMA_NAME(parent_id) + '.' + OBJECT_NAME(parent_id) AS triggered_table
FROM sys.triggers WHERE parent_class = 1 AND is_disabled = 0 ORDER BY 1;"
Expect one row per table needing implicit_returning=False, none on a schema with no triggers.
Keep -m-1: a severity 10 message otherwise prints with no Msg number, and -b will
not fail on one.
-m-1 is an ODBC sqlcmd instruction, meaning the 18.x build from mssql-tools18 or the
Microsoft command line utilities. Measured 2026-09-05, go-sqlcmd 1.10.0, the 1.x build
brew install sqlcmd and winget install sqlcmd install, prints no Msg header on a severity 10
message at any -m value, so on that build a severity 10 message prints with no number whether you
keep the flag or not. Query sys.messages or use the ODBC build when the number matters.
Every model that needs the flag has it. Print the other list and compare:
print(sorted(t.name for t in Base.metadata.tables.values() if t.implicit_returning))
Table.implicit_returning is True by default and False once set, so expect no name from the
sqlcmd output here. Any that appears is the next Msg 334.
The columns the engine actually created. Run this against the migrated database:
SELECT OBJECT_NAME(c.object_id) AS tbl, c.name AS col, t.name AS type_name
FROM sys.columns AS c JOIN sys.types AS t ON t.user_type_id = c.user_type_id
WHERE OBJECTPROPERTY(c.object_id, 'IsUserTable') = 1
AND t.name IN ('varchar', 'char', 'text', 'ntext');
Expect zero rows anywhere text can hold a non-ASCII character. A varchar or char row is a
String column, a text or ntext row is DDL from a dialect that never connected, and the engine
accepts both without complaint, which is why nothing else catches them.
Do not
- Do not claim
fast_executemany=Truesped up an ORM bulk insert without first checking that the statement carries no returning clause. On the default configuration it did not. - Do not inline a credential in an engine URL, and do not call the product
mssql.
References
- SQLAlchemy Microsoft SQL Server dialect: triggers, identity, sequences, fast_executemany, setinputsizes. Fetch it before asserting what the dialect emits.
- insertmanyvalues: what the batched form generates and how to turn it off. Read it when a bulk load is slow or noisy.
- The OUTPUT clause: the restriction in the engine's own words, per DML action. Read it to confirm the rule, not the text.
- Alembic operations reference: the dialect-specific arguments above. Read it while writing a migration that alters or drops a column.
connect-from-python: driver choice, installation, pooling. Open it before writing the URL; the first-party driver has a dialect only from SQLAlchemy 2.1.0b2, not for production.connect-to-azure-sql: encryption, retry, pool sizing. Open it the moment a transient error appears:pool_pre_pingtests a connection at checkout and is not a retry policy.entra-id-auth: passwordless connections, which carry no user, no password and noTrusted_Connection. Open it before putting a token flow near the engine URL.design-azure-sql-schema: key length, sizing, collation. Open it before the first migration.schema-migrations-safely: the migration doctrine Alembic inherits, including why a pipeline can report success on a migration that only warned.