Custom types and events

SQLAlchemy · Expert · 14 min

Encryption, JSON shapes, timezone rules, auditing and multi-tenancy are usually implemented with custom types and events.

A TypeDecorator wraps an existing type and converts values on the way in and out. Set impl to the underlying type, implement process_bind_param(value, dialect) (Python to database) and process_result_value(value, dialect) (database to Python), and set cache_ok = True if the type's state is safe for the statement cache. Common uses: forcing timezone-aware UTC datetimes, storing enums or small value objects as strings, transparent encryption.

Mutation tracking is a classic gotcha: if your type stores a dict or list, in-place changes (obj.data["k"] = 1) are not detected, because the attribute wasn't reassigned. Wrap the type with MutableDict.as_mutable(...) or reassign the value.

Events hook into SQLAlchemy internals with event.listen(target, "name", fn) or @event.listens_for:

  • Engine/connection: before_cursor_execute (logging, query counting), connect (per-connection setup like SQLite PRAGMA foreign_keys=ON).
  • Mapper: before_insert, before_update per object, e.g. timestamps.
  • Session: before_flush to inspect session.new, session.dirty and session.deleted (auditing), after_commit.
  • ORM execute: do_orm_execute to rewrite every ORM query, e.g. global tenant filters with with_loader_criteria.

Events run synchronously inside the flush or execution, so keep them fast and avoid emitting new flushes from within flush events.

Example

import json
from sqlalchemy import create_engine, event, select, String
from sqlalchemy.types import TypeDecorator
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class JSONText(TypeDecorator):
    impl = String
    cache_ok = True
    def process_bind_param(self, value, dialect):
        return None if value is None else json.dumps(value, sort_keys=True)
    def process_result_value(self, value, dialect):
        return None if value is None else json.loads(value)
class Base(DeclarativeBase): pass
class Doc(Base):
    __tablename__ = "docs"
    id: Mapped[int] = mapped_column(primary_key=True)
    meta: Mapped[dict] = mapped_column(JSONText)
@event.listens_for(Doc, "before_insert")
def stamp(mapper, connection, target):
    target.meta = {**target.meta, "v": 1}

engine = create_engine("sqlite://"); Base.metadata.create_all(engine)
with Session(engine) as s:
    s.add(Doc(meta={"tag": "x"})); s.commit()
    print(s.scalar(select(Doc.meta)))  # {'tag': 'x', 'v': 1}

Remember

  • TypeDecorator: impl + process_bind_param + process_result_value
  • Set cache_ok = True when the type is safe to cache
  • In-place changes to dict/list columns need Mutable* wrappers
  • Engine, mapper, session and ORM-execute events hook into every layer

Watch out for

  • Mutating a JSON dict in place and finding the change never got saved.

Check yourself

  1. You do doc.meta['k'] = 2 on a plain TypeDecorator JSON column and commit. What is saved?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.sqlalchemy.org/en/20/core/custom_types.html