Skip to content
AB

Direct SQL with asyncpg: Why I Do Not Add an ORM Automatically

Direct PostgreSQL access can make queries, constraints, and atomic behavior clearer when the team is willing to own the SQL.

Ashish BhakhandPublished

An ORM is useful infrastructure, but it is not a required layer in every Python backend. When the database is PostgreSQL, the schema already exists, and queries rely on database-specific behavior, I often prefer direct SQL with asyncpg.

Code
async def fetch_session(
    pool: asyncpg.Pool,
    user_id: str,
    session_id: UUID,
) -> dict[str, Any] | None:
    sql = """
        select id, name, status, started_at, completed_at
        from workout_sessions
        where id = $1 and user_id = $2::uuid
    """
    async with pool.acquire() as connection:
        row = await connection.fetchrow(sql, session_id, user_id)
    return dict(row) if row else None

The selected columns, tenant boundary, casts, and query are visible together. Parameters remain separate from SQL text.

What direct SQL gives me#

  • Explicit joins, projections, and ordering
  • Natural access to ON CONFLICT, CTEs, window functions, and row locks
  • No accidental lazy queries
  • Clear control over transactions
  • Fewer mappings when PostgreSQL is the intended database

It also makes database knowledge unavoidable. Developers must understand indexes, query plans, constraints, null semantics, and safe migrations.

Keep SQL behind repositories#

Direct SQL should not mean SQL in route functions. Repository functions own persistence details; services speak in application operations.

Map rows before returning them widely. Passing raw Record objects across the system couples domain code to asyncpg and database column names.

When I would add an ORM#

An ORM becomes attractive when the product is dominated by conventional related CRUD, the team benefits from model-driven migrations, or query composition is more important than database-specific control. Django's ORM is particularly valuable when used with Django admin and its broader model ecosystem.

SQLAlchemy can also provide transaction and mapping structure without requiring Django.

The decision is not “raw SQL is fast.” Poor direct SQL can be slow and unsafe. The decision is whether explicit database behavior or model-level productivity removes more risk for this application. Choose deliberately, then give that choice a clear boundary.

← Back to all writing