Fixing RemovedIn20Warning Deprecation Warnings in SQLAlchemy

Set SQLALCHEMY_WARN_20=1 before running your application or test suite, then convert each RemovedIn20Warning into a hard error with filterwarnings("error", category=RemovedIn20Warning) in your pytest conftest.py to surface every legacy call before upgrading to SQLAlchemy 2.0.

This page is part of the Migrating Legacy 1.4 Code to 2.0 Syntax guide.


Quick Answer

Enable all warnings with the environment variable:

SQLALCHEMY_WARN_20=1 pytest
SQLALCHEMY_WARN_20=1 python -m myapp.main

Convert warnings to hard errors in conftest.py:

# tests/conftest.py
import warnings
from sqlalchemy.exc import RemovedIn20Warning

def pytest_configure(config):
    warnings.filterwarnings(
        "error",
        category=RemovedIn20Warning,
    )

Before (SQLAlchemy 1.4 legacy style):

# Legacy: triggers RemovedIn20Warning on every call
from sqlalchemy.orm import Session

def get_user(session: Session, user_id: int):
    return session.query(User).filter(User.id == user_id).one()

def get_order(session: Session, order_id: int):
    return session.query(Order).get(order_id)

def run_raw(engine):
    result = engine.execute("SELECT count(*) FROM invoices")
    return result.scalar()

After (SQLAlchemy 2.0 style):

# Modern: no warnings, fully compatible with 2.0
from sqlalchemy import select
from sqlalchemy.orm import Session

def get_user(session: Session, user_id: int):
    return session.execute(
        select(User).where(User.id == user_id)
    ).scalars().one()

def get_order(session: Session, order_id: int):
    return session.get(Order, order_id)

def run_raw(engine):
    with engine.connect() as conn:
        result = conn.execute(text("SELECT count(*) FROM invoices"))
        return result.scalar()

Execution Context & Async Workflow Integration

RemovedIn20Warning is a special warning class introduced in SQLAlchemy 1.4 that marks every API surface slated for removal in 2.0. By default SQLAlchemy 1.4 suppresses most of these warnings to avoid flooding application logs — the standard Python DeprecationWarning filter silences them unless you opt in. The SQLALCHEMY_WARN_20=1 environment variable overrides that suppression and re-registers all RemovedIn20Warning instances as visible.

This two-stage design — opt-in visibility, then opt-in failure — was intentional. It gave teams a migration runway: run 1.4, see warnings in CI, fix them incrementally, then flip the switch to 2.0 with confidence. If you are on 2.0 already and still see RemovedIn20Warning in your logs, you have a dependency that has not updated its SQLAlchemy calls or you are importing from a sqlalchemy.orm.compat shim.

Why async workflows surface warnings sooner. When you move to async engines (postgresql+asyncpg://) the session lifecycle rules change fundamentally. The old session.query() interface has no async equivalent — there is no await session.query(User).all(). Any code path that reaches a legacy Query object in an async context will fail with an MissingGreenlet error rather than a gentle warning. This makes RemovedIn20Warning especially urgent for async migrations: the warnings you ignore in sync code become hard crashes the moment you add asyncpg.

Turning warnings into CI blockers. The pytest_configure hook runs before test collection, which means the filter is in place before any SQLAlchemy model or engine is imported. That matters because some warnings fire at import time (for example, declarative_base() emits a warning when the class is constructed). Placing the filterwarnings call in pytest_configure rather than a pytest.fixture guarantees full coverage.

For non-pytest application startup, add the filter immediately after your imports and before your engine is created:

# app/db.py
import warnings
from sqlalchemy import create_engine, text
from sqlalchemy.exc import RemovedIn20Warning

# Surface legacy calls as errors during development
if __debug__:  # disabled when Python runs with -O flag
    warnings.filterwarnings("error", category=RemovedIn20Warning)

engine = create_engine(
    "postgresql+asyncpg://user:pass@localhost/mydb",
    future=True,  # required in 1.4 to enable 2.0 behaviour
)

The future=True flag on create_engine (1.4 only) opts the engine into 2.0 connection semantics. It does not silence warnings — it changes runtime behaviour so that connection-level warnings fire immediately rather than being deferred.


Resolving Warnings, Errors & Common Mistakes

The table below lists the exact warning strings emitted by SQLAlchemy 1.4 (SQLALCHEMY_WARN_20=1 active), the root cause, and the minimal production-safe fix. All fixes are drop-in replacements that work on both 1.4 (future=True) and 2.0.

Exact Warning / Error StringRoot CauseProduction Fix
RemovedIn20Warning: The Query.get() method is considered legacy as of version 1.4session.query(User).get(pk) uses the legacy Query interface to load by primary keyReplace with session.get(User, pk) — identical semantics, zero overhead
RemovedIn20Warning: The Session.execute() method now accepts 2.0-style arguments; use text() for string SQLsession.execute("SELECT …") passes a bare string; 2.0 requires an explicit text() constructsession.execute(text("SELECT …")) — also import text from sqlalchemy
RemovedIn20Warning: The legacy calling style of select() is deprecated and will be removed in SQLAlchemy 2.0select([User]) (list argument) instead of select(User) (positional, no brackets)Replace select([Model]) with select(Model) throughout
RemovedIn20Warning: The Query object is considered legacy as of version 1.4Any session.query(…) call, including chained .filter(), .join(), .order_by()Rewrite as select(Model).where(…) executed via session.execute().scalars()
RemovedIn20Warning: Engine.execute() is deprecatedengine.execute(stmt) bypasses connection lifecycle managementwith engine.connect() as conn: conn.execute(stmt) — wraps in explicit connection
RemovedIn20Warning: The autocommit parameter is deprecatedengine = create_engine(url, execution_options={"autocommit": True}) or connection.execution_options(autocommit=True)Use explicit with session.begin(): blocks or conn.begin() — see transaction isolation strategies
RemovedIn20Warning: declarative_base() is moved to the DeclarativeBaseNoMeta or DeclarativeBaseBase = declarative_base() at module levelSubclass DeclarativeBase: class Base(DeclarativeBase): pass
RemovedIn20Warning: relationship() keyword 'backref' is deprecatedrelationship("Order", backref="user") auto-creates the reverse — implicit and fragileDeclare both sides explicitly: relationship("Order", back_populates="user") and relationship("User", back_populates="orders")
RemovedIn20Warning: Column() is deprecated in favor of mapped_column()Column(Integer, primary_key=True) without Mapped type annotationid: Mapped[int] = mapped_column(primary_key=True) — see type annotations guide

Fixing session.query() comprehensively. The Query object supports a wide surface area — .filter(), .join(), .options(), .with_for_update(), .distinct(), .group_by(). Every one of these has a direct select() equivalent. The mechanical translation is:

# Before
from sqlalchemy.orm import Session, joinedload

def get_tenant_orders(session: Session, tenant_id: int) -> list[Order]:
    return (
        session.query(Order)
        .filter(Order.tenant_id == tenant_id, Order.status == "pending")
        .options(joinedload(Order.products))
        .order_by(Order.created_at.desc())
        .limit(50)
        .all()
    )

# After
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload

def get_tenant_orders(session: Session, tenant_id: int) -> list[Order]:
    stmt = (
        select(Order)
        .where(Order.tenant_id == tenant_id, Order.status == "pending")
        .options(joinedload(Order.products))
        .order_by(Order.created_at.desc())
        .limit(50)
    )
    return session.execute(stmt).scalars().all()

Note scalars() before .all(). Without it session.execute() returns Row tuples, not ORM instances. This is the single most common mistake when rewriting session.query() calls.

Fixing declarative_base() and column declarations together:

# Before — triggers two distinct warnings
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import declarative_base, relationship

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    email = Column(String(255), nullable=False, unique=True)
    orders = relationship("Order", backref="user")

class Order(Base):
    __tablename__ = "orders"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    total = Column(Integer, nullable=False)

# After — fully 2.0 native
from __future__ import annotations
from typing import Optional
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
    orders: Mapped[list[Order]] = relationship(back_populates="user")

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
    total: Mapped[int] = mapped_column(nullable=False)
    user: Mapped[User] = relationship(back_populates="orders")

The from __future__ import annotations import is required at the top of model files that use forward references (list[Order] inside User before Order is defined). Without it Python evaluates annotations eagerly and raises NameError on the undefined name.


Advanced Warning Audit Optimization

Use warnings.warn_explicit tracing to find the call site, not just the warning type.

When a warning fires from inside a shared utility or a third-party library, the standard traceback points to SQLAlchemy internals rather than your code. The stacklevel parameter on the warning is set by SQLAlchemy to walk up the call stack — but if the legacy call is buried three layers deep in your own helpers, the reported line number is still wrong.

Add a custom warning filter that logs the full stack at the point of emission:

# tests/conftest.py
import warnings
import traceback
import logging
from sqlalchemy.exc import RemovedIn20Warning

logger = logging.getLogger("sqlalchemy.migration")

_original_showwarning = warnings.showwarning

def _trace_sqlalchemy_warnings(message, category, filename, lineno, file=None, line=None):
    if issubclass(category, RemovedIn20Warning):
        logger.warning(
            "RemovedIn20Warning at %s:%d%s\n%s",
            filename,
            lineno,
            message,
            "".join(traceback.format_stack()),
        )
    _original_showwarning(message, category, filename, lineno, file, line)

def pytest_configure(config):
    warnings.showwarning = _trace_sqlalchemy_warnings
    warnings.filterwarnings("error", category=RemovedIn20Warning)

This captures the full Python stack at the moment the warning fires, even when the emission point is inside a @contextmanager or a framework middleware layer. Run pytest -s to see the stacks in real time, or check your log output. The trace makes it unambiguous which line in your application code is responsible — not just which SQLAlchemy internal method detected the problem.

This approach is particularly effective in codebases that have abstracted database access behind repository classes or service layers, where the session.query() call might live several frames away from the test that triggers it.


Frequently Asked Questions

I upgraded to SQLAlchemy 2.0 and I still see RemovedIn20Warning — why? On SQLAlchemy 2.0 the RemovedIn20Warning class still exists in sqlalchemy.exc for compatibility, but the warnings themselves are only emitted by 1.4 code paths. If you see them on 2.0 it is almost always a transitive dependency — a library that pinned to the 1.4 API. Run pip show sqlalchemy-utils alembic sqlmodel (and any other SQLAlchemy-adjacent packages in your requirements.txt) and check their changelogs for 2.0 support. You can also set filterwarnings("error") globally in conftest.py to find the exact import that introduces the legacy calls.

Does filterwarnings("error", category=RemovedIn20Warning) break production? It should not run in production. Guard it with an environment variable or if __debug__: (which is False when Python runs with the -O optimisation flag). The recommended pattern is to enable it only in test environments via conftest.py and in local development via a .env file that sets SQLALCHEMY_WARN_20=1. CI picks up conftest.py automatically, making it the right enforcement boundary.

What is the difference between SQLALCHEMY_WARN_20=1 and future=True on the engine? They control different things. SQLALCHEMY_WARN_20=1 is an environment variable that tells SQLAlchemy to emit warnings for every legacy API call, regardless of whether the engine is in legacy or future mode. future=True on create_engine() opts the engine into 2.0 runtime semantics — it changes how connections and transactions behave, not which warnings are visible. You need both during a migration: future=True to test 2.0 connection behaviour, and SQLALCHEMY_WARN_20=1 to see all the ORM-level calls that still need updating.

Can I suppress specific RemovedIn20Warning instances temporarily while migrating a large codebase? Yes. Python's warnings.filterwarnings accepts a message regex parameter that matches against the warning string. You can silence one category of warning while keeping others as errors:

import warnings
from sqlalchemy.exc import RemovedIn20Warning

# Silence only the backref warning while we migrate relationships gradually
warnings.filterwarnings(
    "ignore",
    message=r".*backref.*",
    category=RemovedIn20Warning,
)

# Everything else still raises
warnings.filterwarnings(
    "error",
    category=RemovedIn20Warning,
)

Order matters: Python applies the first matching filter. Put the specific ignore rules before the broad error rule. Remove each ignore entry as you complete that category of fixes so you do not accumulate permanent suppressions.