Back to Blog
Python

Python SQLAlchemy ORM vs Core: Which to Use

python sqlalchemy orm vs core: Compare SQLAlchemy Core and ORM APIs: their design, query construction, performance tradeoffs, and which to pick for your Python project.

SQLAlchemyORMSQLAlchemy CorePythonDatabaseSQL
A visual comparison of SQLAlchemy Core and ORM showing a direct SQL path and an object mapping path to the same database.

The choice between SQLAlchemy Core and the SQLAlchemy ORM is not a matter of one being better than the other. They are two distinct APIs built on the same SQLAlchemy engine, and each serves a different set of requirements. Understanding the difference between python sqlalchemy orm vs core starts with recognizing that Core is a SQL abstraction toolkit, while the ORM is a domain model layer that uses Core underneath.

The Core and ORM Are Two Different APIs for the Same Engine

SQLAlchemy Core is the foundation. It provides a schema-centric, SQL-expression language that lets you construct and execute SQL statements directly. You work with tables, columns, and SQL constructs as Python objects, but you remain in control of the SQL that gets generated.

The ORM, on the other hand, is built on top of Core. It maps Python classes to database tables and lets you interact with rows as if they were ordinary Python objects. The ORM handles the translation between object operations and SQL statements, and it adds features like identity mapping, unit of work, and relationship loading.

Both APIs share the same connection pool, dialect system, and execution machinery. That means the underlying database interaction is identical. What changes is the level of abstraction and the amount of automatic behavior you get.

How SQLAlchemy Core Works

With Core, you define a Table object that describes the database schema. You then use SQL expression language to build queries. Here is a minimal example:

from sqlalchemy import create_engine, Table, Column, Integer, String, MetaData, select engine = create_engine("postgresql+psycopg2://user:pass@localhost/db") metadata = MetaData() users = Table( "users", metadata, Column("id", Integer, primary_key=True), Column("name", String(50)), Column("email", String(120)), ) # Build a query stmt = select(users.c.id, users.c.name).where(users.c.email == "alice@example.com") # Execute it directly with engine.connect() as conn: result = conn.execute(stmt) for row in result: print(row.id, row.name)

The query is built from column objects and comparison operators. The result is a Row object that behaves like a tuple and also supports attribute access. There is no automatic tracking of changes, no session, and no relationship loading. You are responsible for every SQL statement you issue.

Core gives you fine-grained control over the SQL. You can write complex joins, subqueries, unions, and even raw SQL fragments when needed. This makes it a good fit for reporting, data migration, and any code that needs to be explicit about what runs against the database.

How the SQLAlchemy ORM Works

The ORM introduces a Session that tracks object state and flushes changes to the database. You define classes that inherit from a declarative base, and each class maps to a table. Here is the equivalent example using the ORM:

from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy.orm import declarative_base, sessionmaker engine = create_engine("postgresql+psycopg2://user:pass@localhost/db") Base = declarative_base() class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) name = Column(String(50)) email = Column(String(120)) Session = sessionmaker(bind=engine) session = Session() # Query using the ORM's query API users = session.query(User).filter(User.email == "alice@example.com").all() for user in users: print(user.id, user.name)

The ORM query returns User instances. The session tracks these objects, and if you modify them, the changes are persisted when you call session.commit(). This is the unit of work pattern: the session accumulates changes and sends them to the database in a single transaction.

The ORM also handles relationships. You can define relationship() on a class and access related objects without writing joins manually. For example, if a user has many posts, user.posts will lazily load them on first access, or eagerly load them if you configure the query accordingly.

Key Differences in Query Construction

The most immediate difference between Core and ORM is how queries are written. Core uses select() with table columns, while the ORM uses session.query() or the newer select() with mapped classes. The ORM also allows you to chain filters and use Python operators that translate to SQL.

Here is a side-by-side comparison for a simple join:

# Core stmt = ( select(users.c.name, posts.c.title) .join(posts, users.c.id == posts.c.user_id) .where(users.c.id == 1) ) # ORM (modern style) stmt = ( select(User.name, Post.title) .join(Post, User.id == Post.user_id) .where(User.id == 1) )

Both produce the same SQL. The ORM version uses the mapped classes to resolve table and column names, but the generated SQL is identical. The difference is in the surrounding machinery: the ORM will also populate the identity map and may trigger relationship loading, while Core simply returns rows.

Another difference is how updates are handled. In Core, you write an explicit update() statement:

from sqlalchemy import update stmt = update(users).where(users.c.id == 1).values(name="Alice Smith") with engine.connect() as conn: conn.execute(stmt) conn.commit()

In the ORM, you modify the object and commit:

user = session.get(User, 1) user.name = "Alice Smith" session.commit()

The ORM automatically generates the UPDATE statement when the session flushes. This is convenient, but it also means you need to understand the session lifecycle and be careful about detached objects and stale data.

When to Use SQLAlchemy Core

Core is the right choice when you need direct control over the SQL that reaches the database. This includes scenarios like:

  • Reporting and analytics: Queries that involve complex aggregations, window functions, or database-specific features are easier to write and debug with Core.
  • Bulk operations: Inserting or updating thousands of rows is more efficient with Core's insert() and update() constructs because you avoid the overhead of object tracking.
  • Dynamic query construction: If your query structure depends on runtime input, Core's expression language lets you build statements programmatically without the ORM's session context.
  • Existing raw SQL: When you already have SQL queries and want to parameterize them, Core's text() construct is a natural fit.

Core also avoids the hidden behavior of the ORM. There is no lazy loading, no identity map, and no automatic flush. This makes the execution path more predictable, which is valuable in performance-sensitive code.

When to Use the SQLAlchemy ORM

The ORM shines when your application is centered around domain objects and you want to reduce the amount of SQL boilerplate. It is a good fit for:

  • CRUD applications: Creating, reading, updating, and deleting records maps naturally to object operations.
  • Relationship-heavy models: When you have complex object graphs with foreign keys, the ORM's relationship loading saves you from writing join logic repeatedly.
  • Rapid development: The ORM lets you focus on Python objects and lets the session handle the persistence details.
  • Code maintainability: If your team thinks in terms of models rather than tables, the ORM can make the data layer more approachable.

The ORM does have a learning curve. You need to understand sessions, identity maps, lazy loading, and the various loading strategies to avoid performance pitfalls. But for a typical web application, the productivity gain is often worth it.

Performance and Runtime Overhead

The ORM adds overhead compared to Core, but the magnitude depends on how you use it. The session tracks every object it loads, which consumes memory. The identity map ensures that the same primary key returns the same object instance within a session, but that also means objects stay in memory until the session is closed or expired.

Lazy loading is a common source of performance problems. When you access a relationship that hasn't been loaded, the ORM issues a separate query. In a loop, this can lead to the N+1 query problem. Core does not have this issue because you write the join explicitly.

The ORM also has a flush process. When you call commit(), the session determines which objects changed and issues UPDATE statements. This requires tracking object state, which adds CPU and memory overhead. For bulk operations, this overhead is significant. Core's execute() method sends the statement directly, without any object tracking.

That said, the ORM can be optimized. You can use eager loading with joinedload() or selectinload() to avoid N+1 queries. You can also use bulk_update_mappings() for large updates. But these are additional concepts you must learn and apply correctly.

Core does not automatically give you better performance; it gives you fewer hidden costs. The SQL generated by both APIs is the same, so the database execution time is identical. The difference is in the Python-side processing and the amount of data loaded into memory.

Maintainability and Team Context

The choice between Core and ORM often comes down to team familiarity and the application's architecture. A team that is comfortable with SQL may find Core more transparent and easier to reason about. A team that prefers object-oriented design may find the ORM more natural.

Core's explicitness can be a maintenance advantage. When you read a Core query, you see exactly what SQL will run. There is no hidden behavior. This makes debugging easier, especially when a query is slow or returns unexpected results.

The ORM's implicit behavior can be a double-edged sword. It reduces boilerplate, but it also hides details. A developer new to the codebase may not realize that accessing user.posts triggers a query, or that modifying an object requires a commit to be persisted. This can lead to subtle bugs and performance issues.

For a long-lived project, the decision should be based on the team's expertise and the types of queries the application needs. If the application is mostly CRUD with simple relationships, the ORM is usually the better choice. If the application has complex reporting, analytics, or data-heavy operations, Core is often more appropriate.

A Practical Decision Framework

Instead of asking "which is better," ask "what does my code need to do?" Here is a concrete way to decide:

  • Use Core when you need to write complex SQL, perform bulk operations, or build dynamic queries where the structure is not known until runtime.
  • Use ORM when your application is a standard web app with clear domain models, relationships, and typical CRUD operations.
  • Use both in the same project when necessary. SQLAlchemy allows you to mix Core and ORM. You can use the ORM for standard model access and fall back to Core for specific queries that need fine-grained control.

A common pattern is to use the ORM for the main application logic and Core for reporting endpoints or background jobs that need to process large datasets. This hybrid approach leverages the strengths of each API without forcing a single style across the entire codebase.

When you start a new project, consider the long-term maintenance cost. If you choose the ORM, invest time in learning its loading strategies and session management. If you choose Core, be prepared to write more SQL and manage transactions manually. Both are valid, but they require different skills and attention.

Ultimately, python sqlalchemy orm vs core is not a competition. It is a decision about how much abstraction you want between your Python code and your database. The right answer depends on the specific requirements of your application, the skills of your team, and the performance characteristics you need to meet.

python sqlalchemy orm vs core: Practical Usage and Code Exam | RYUSLOG DEV