Skip to content

Connection & Registry

Functions for managing database connections and the global model registry. connect() registers a (optionally named) connection pool. reset_engine() tears everything down. The registry helpers control schema creation and the identity map. Sessionized routing is exposed via ferro.engines.session(name) / ferro.Session. See the Connections & Databases guide.

Session dataclass

A unit of work: one connection scope, one identity map, one settings set.

settings are session settings — Postgres settings (GUCs) that Ferro applies to every operation this session runs, so queries are scoped without a per-query where:

async with engines.session(settings={"myapp.tenant_id": "acme"}):
    # Postgres sees myapp.tenant_id = 'acme' for every statement this
    # session sends, which is what a row-level-security policy reads
    # back — inside a transaction() block or, as here, outside one.
    invoices = await Invoice.where(lambda invoice: invoice.paid).all()

They are validated when the Session is constructed and again when it is entered, which is when the effective set — this session's settings merged over anything it inherits from an enclosing session — is snapshotted. See EngineManager.session for the full contract, including what an operation outside a transaction sends and how inherited settings behave off Postgres.

A value not known until partway through the session's life — resolved from an auth chain, say — is set with set_config rather than at open; see there for the mid-transaction and identity-map-eviction contract.

Source code in src/ferro/session.py
@dataclass(slots=True)
class Session:
    """A unit of work: one connection scope, one identity map, one settings set.

    `settings` are *session settings* — Postgres settings (GUCs) that Ferro
    applies to every operation this session runs, so queries are scoped without
    a per-query `where`:

        async with engines.session(settings={"myapp.tenant_id": "acme"}):
            # Postgres sees myapp.tenant_id = 'acme' for every statement this
            # session sends, which is what a row-level-security policy reads
            # back — inside a transaction() block or, as here, outside one.
            invoices = await Invoice.where(lambda invoice: invoice.paid).all()

    They are validated when the `Session` is constructed and again when it is
    entered, which is when the effective set — this session's settings merged
    over anything it inherits from an enclosing session — is snapshotted. See
    `EngineManager.session` for the full contract, including what an operation
    outside a transaction sends and how inherited settings behave off Postgres.

    A value not known until partway through the session's life — resolved
    from an auth chain, say — is set with `set_config` rather than at open;
    see there for the mid-transaction and identity-map-eviction contract.
    """

    connection_name: str | None = None
    session_id: str | None = None
    settings: Mapping[str, str] | None = None
    effective_settings: dict[str, str] = field(
        default_factory=dict, init=False, repr=False, compare=False
    )
    _token: Any = field(default=None, repr=False, compare=False)
    _enter_context: contextvars.Context | None = field(
        default=None, repr=False, compare=False
    )
    _enter_task: asyncio.Task[Any] | None = field(
        default=None, repr=False, compare=False
    )
    _close_lock: asyncio.Lock | None = field(default=None, repr=False, compare=False)

    def __post_init__(self) -> None:
        # Eager validation: a bad key or value raises where it was written, not
        # on the first query that would have carried it. `settings` is left
        # exactly as passed and checked again at enter, which is the moment its
        # values are actually read.
        _validated_settings(self.settings)

    async def __aenter__(self) -> "Session":
        # Re-validate rather than trusting the construction-time check: the
        # caller may have reassigned `settings` or mutated their mapping since,
        # and what gets applied has to be what was checked.
        declared = _validated_settings(self.settings)
        self.effective_settings = self._merge_ambient_settings(declared)
        self.session_id, resolved_name = _core_open_session(
            self.connection_name,
            list(self.effective_settings.items()),
            list(declared.items()),
        )
        if self.connection_name is None:
            self.connection_name = resolved_name
        self._token = _CURRENT_SESSION.set(self)
        self._enter_context = contextvars.copy_context()
        self._enter_task = asyncio.current_task()
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        try:
            await self.close()
        except Exception as close_exc:
            if exc_type is None:
                raise
            if exc is not None:
                raise exc from close_exc
            raise close_exc

    async def close(self) -> None:
        """Close this session and release its runtime state.

        Under ``settings_delivery="connection"`` this is also where the
        connection the session pinned goes back to the pool — after Ferro
        resets **exactly the settings keys this session set on it**, and
        nothing else. (Never ``RESET ALL``: that would also wipe the
        ``search_path`` the pool installs on every connection.) A session
        under the default ``transaction`` delivery has nothing to release and
        this costs no round trip.

        Safe to call from a different asyncio context than ``__aenter__``.
        Repeated calls are no-ops.

        Raises:
            RuntimeError: If the ambient session in this asyncio context does not
                match this handle (same-context lifecycle misuse), or if
                session-scoped transactions are still open, or if resetting a
                pinned connection failed — in which case the session is closed
                regardless and the connection was discarded rather than
                returned to the pool.
        """
        if self._close_lock is None:
            self._close_lock = asyncio.Lock()

        async with self._close_lock:
            if self.session_id is None and self._token is None:
                return

            self._assert_close_allowed()

            releasing = None
            if self.session_id is not None:
                # Rust takes the session out of its registry *here*, on the
                # call itself, and rejects the close outright — before any
                # awaitable exists — when transactions are still open. So an
                # exception from this line leaves the handle exactly as it
                # was: still open, still usable.
                releasing = _core_close_session(self.session_id)
                self.session_id = None

            try:
                if releasing is not None:
                    # Awaits a round trip only under `connection` settings
                    # delivery, where the pinned connection's settings are
                    # reset before it goes back to the pool.
                    await releasing
            finally:
                # Past that point the session is gone from the runtime
                # whatever happens, so the ambient session is restored
                # whatever happens: a failed reset must not leave the rest of
                # this context scoped to a session that no longer exists.
                self._detach_ambient()

    def _detach_ambient(self) -> None:
        """Stop being the ambient session for this asyncio context."""
        if self._token is None:
            self._enter_context = None
            self._enter_task = None
            return
        token = self._token
        self._token = None
        self._enter_context = None
        self._enter_task = None
        self._restore_ambient_session(token)

    def _assert_close_allowed(self) -> None:
        if self._token is None:
            return
        if asyncio.current_task() is not self._enter_task:
            return
        ambient = _CURRENT_SESSION.get()
        if ambient is self:
            return
        entered_ambient = (
            self._enter_context.get(_CURRENT_SESSION)
            if self._enter_context is not None
            else None
        )
        if entered_ambient is self and ambient is not None:
            raise RuntimeError(_SESSION_CLOSE_AMBIENT_MISMATCH)

    def _restore_ambient_session(self, token: Any) -> None:
        ambient = _CURRENT_SESSION.get()
        if ambient is not self:
            try:
                _CURRENT_SESSION.reset(token)
            except ValueError:
                return
            raise RuntimeError(_SESSION_CLOSE_AMBIENT_MISMATCH)
        try:
            _CURRENT_SESSION.reset(token)
        except ValueError:
            return

    def _merge_ambient_settings(self, declared: dict[str, str]) -> dict[str, str]:
        """Snapshot the settings this session runs with, at enter.

        A session opened inside another one starts from the outer session's
        effective settings and overrides them key by key, so helper code that
        opens its own session stays scoped:

            async with engines.session(settings={"myapp.tenant_id": "acme"}):
                async with engines.session(settings={"myapp.role": "auditor"}):
                    # myapp.tenant_id == 'acme' and myapp.role == 'auditor'
                    ...

        The merge is a snapshot: nothing propagates between the two sessions
        afterwards, and closing the inner one leaves the outer exactly as it was.

        Args:
            declared: This session's own validated settings.
        """
        declared = dict(declared)
        ambient = _CURRENT_SESSION.get()
        inherited = getattr(ambient, "effective_settings", None) if ambient else None
        if not inherited:
            return declared
        return {**inherited, **declared}

    async def set_config(self, key: str, value: str) -> None:
        """Mutate this session's settings while it is open.

        Everything you can `settings=` at open, you can also set later, for the
        case where the value isn't known until partway through a request — an
        auth chain that resolves the tenant only after checking a token, say:

            async with engines.session() as session:      # no settings yet
                tenant = await resolve_tenant_from_auth_header(request)
                await session.set_config("myapp.tenant_id", tenant)
                # every query from here on is scoped to `tenant`
                invoices = await Invoice.where(lambda invoice: invoice.paid).all()

        Deep in a call stack, reach the session through `ferro.current_session()`
        rather than threading it through every function signature:

            async def resolve_tenant_and_scope(request) -> None:
                tenant = await resolve_tenant_from_auth_header(request)
                await ferro.current_session().set_config("myapp.tenant_id", tenant)

        **When the new value takes effect, precisely.** Any operation or
        `transaction()` STARTED after `set_config` returns sees the new value
        — this holds for any task, not just the one that called `set_config`,
        because the change is committed before `set_config`'s `await`
        returns. An operation or transaction already in flight *when*
        `set_config` runs keeps running under the scope it started with —
        that is the existing per-operation-atomicity guarantee, working as
        intended, not something `set_config` reaches back into. The one place
        that needs (and gets) special handling is a `transaction()` block
        already open in the SAME task that calls `set_config` — there, the
        very next statement in that same transaction sees the new value
        immediately, rather than waiting for the transaction to end and a new
        one to begin (there is no race to resolve here: the two are
        sequential code in one task, by construction):

            async with engines.session() as session:
                async with transaction():
                    await session.set_config("myapp.tenant_id", "acme")
                    # this SELECT, in the SAME transaction, already sees it
                    rows = await Invoice.where(lambda invoice: invoice.paid).all()

        `set_config` also evicts this session's identity map on a real change:
        an instance `get()`-ed under the old scope is never handed back under
        the new one — the next `get()` for that primary key refetches instead.
        A call that doesn't actually change the value (the key already holds
        it) is a true no-op: no database round trip, no identity-map eviction.

        Validation matches `engines.session(settings=...)` exactly (see there
        for the full rationale): `key` and `value` must be `str`, and `key` must
        be a dotted custom setting name.

        Args:
            key: Dotted custom Postgres setting name (e.g. `"myapp.tenant_id"`).
            value: The setting's new value.

        Raises:
            RuntimeError: This session is not open (`set_config` mutates a live
                session, so there has to be one), or this session's connection
                is not Postgres — `set_config` is a declaration, exactly like
                `settings=` at open, so it has to be honourable rather than a
                silent no-op on a backend with no GUCs. On the rare failure
                while delivering the change into an already-open transaction,
                this session's recorded settings and identity map have
                already committed the change regardless (see the Ferro source
                for the exact sequencing) — the affected transaction cannot go
                on to leak an old-scope read, because Postgres aborts a
                transaction after a failed statement until it is rolled back.
            TypeError: `key` or `value` is not a `str`.
            ValueError: `key` is not a dotted custom setting name.
        """
        if self.session_id is None:
            raise RuntimeError(_SET_CONFIG_NOT_OPEN_MESSAGE)
        if self.connection_name is None:
            # `connection_name` is resolved by `__aenter__` alongside
            # `session_id` (see there), so a live `session_id` should make
            # this unreachable. Raised rather than asserted (`assert` strips
            # under `-O`) because this is a Ferro lifecycle invariant, not an
            # ordinary user mistake.
            raise RuntimeError(_SET_CONFIG_NOT_OPEN_MESSAGE)

        validated = _validated_settings({key: value})
        ((validated_key, validated_value),) = validated.items()

        # Only the one validated pair is sent — Rust merges it against the
        # session's own last-committed settings, inside a lock that
        # serializes the read and the write together, so two sibling tasks
        # setting different keys concurrently can never lose one to a
        # stale-mirror race (see `operations::set_session_config`). The
        # mirror below is always assigned from what Rust actually committed,
        # never computed here — both when this call changed something and
        # when it was a no-op.
        committed = await _core_set_session_config(
            self.session_id,
            self.connection_name,
            validated_key,
            validated_value,
        )
        self.effective_settings = dict(committed)

    def query(self, model_cls):
        from .query import Query

        return Query(model_cls, session=self)

Attributes

connection_name = None class-attribute instance-attribute

session_id = None class-attribute instance-attribute

settings = None class-attribute instance-attribute

effective_settings = field(default_factory=dict, init=False, repr=False, compare=False) class-attribute instance-attribute

Functions

__post_init__()

Source code in src/ferro/session.py
def __post_init__(self) -> None:
    # Eager validation: a bad key or value raises where it was written, not
    # on the first query that would have carried it. `settings` is left
    # exactly as passed and checked again at enter, which is the moment its
    # values are actually read.
    _validated_settings(self.settings)

__aenter__() async

Source code in src/ferro/session.py
async def __aenter__(self) -> "Session":
    # Re-validate rather than trusting the construction-time check: the
    # caller may have reassigned `settings` or mutated their mapping since,
    # and what gets applied has to be what was checked.
    declared = _validated_settings(self.settings)
    self.effective_settings = self._merge_ambient_settings(declared)
    self.session_id, resolved_name = _core_open_session(
        self.connection_name,
        list(self.effective_settings.items()),
        list(declared.items()),
    )
    if self.connection_name is None:
        self.connection_name = resolved_name
    self._token = _CURRENT_SESSION.set(self)
    self._enter_context = contextvars.copy_context()
    self._enter_task = asyncio.current_task()
    return self

__aexit__(exc_type, exc, tb) async

Source code in src/ferro/session.py
async def __aexit__(self, exc_type, exc, tb) -> None:
    try:
        await self.close()
    except Exception as close_exc:
        if exc_type is None:
            raise
        if exc is not None:
            raise exc from close_exc
        raise close_exc

close() async

Close this session and release its runtime state.

Under settings_delivery="connection" this is also where the connection the session pinned goes back to the pool — after Ferro resets exactly the settings keys this session set on it, and nothing else. (Never RESET ALL: that would also wipe the search_path the pool installs on every connection.) A session under the default transaction delivery has nothing to release and this costs no round trip.

Safe to call from a different asyncio context than __aenter__. Repeated calls are no-ops.

Raises:

Type Description
RuntimeError

If the ambient session in this asyncio context does not match this handle (same-context lifecycle misuse), or if session-scoped transactions are still open, or if resetting a pinned connection failed — in which case the session is closed regardless and the connection was discarded rather than returned to the pool.

Source code in src/ferro/session.py
async def close(self) -> None:
    """Close this session and release its runtime state.

    Under ``settings_delivery="connection"`` this is also where the
    connection the session pinned goes back to the pool — after Ferro
    resets **exactly the settings keys this session set on it**, and
    nothing else. (Never ``RESET ALL``: that would also wipe the
    ``search_path`` the pool installs on every connection.) A session
    under the default ``transaction`` delivery has nothing to release and
    this costs no round trip.

    Safe to call from a different asyncio context than ``__aenter__``.
    Repeated calls are no-ops.

    Raises:
        RuntimeError: If the ambient session in this asyncio context does not
            match this handle (same-context lifecycle misuse), or if
            session-scoped transactions are still open, or if resetting a
            pinned connection failed — in which case the session is closed
            regardless and the connection was discarded rather than
            returned to the pool.
    """
    if self._close_lock is None:
        self._close_lock = asyncio.Lock()

    async with self._close_lock:
        if self.session_id is None and self._token is None:
            return

        self._assert_close_allowed()

        releasing = None
        if self.session_id is not None:
            # Rust takes the session out of its registry *here*, on the
            # call itself, and rejects the close outright — before any
            # awaitable exists — when transactions are still open. So an
            # exception from this line leaves the handle exactly as it
            # was: still open, still usable.
            releasing = _core_close_session(self.session_id)
            self.session_id = None

        try:
            if releasing is not None:
                # Awaits a round trip only under `connection` settings
                # delivery, where the pinned connection's settings are
                # reset before it goes back to the pool.
                await releasing
        finally:
            # Past that point the session is gone from the runtime
            # whatever happens, so the ambient session is restored
            # whatever happens: a failed reset must not leave the rest of
            # this context scoped to a session that no longer exists.
            self._detach_ambient()

set_config(key, value) async

Mutate this session's settings while it is open.

Everything you can settings= at open, you can also set later, for the case where the value isn't known until partway through a request — an auth chain that resolves the tenant only after checking a token, say:

async with engines.session() as session:      # no settings yet
    tenant = await resolve_tenant_from_auth_header(request)
    await session.set_config("myapp.tenant_id", tenant)
    # every query from here on is scoped to `tenant`
    invoices = await Invoice.where(lambda invoice: invoice.paid).all()

Deep in a call stack, reach the session through ferro.current_session() rather than threading it through every function signature:

async def resolve_tenant_and_scope(request) -> None:
    tenant = await resolve_tenant_from_auth_header(request)
    await ferro.current_session().set_config("myapp.tenant_id", tenant)

When the new value takes effect, precisely. Any operation or transaction() STARTED after set_config returns sees the new value — this holds for any task, not just the one that called set_config, because the change is committed before set_config's await returns. An operation or transaction already in flight when set_config runs keeps running under the scope it started with — that is the existing per-operation-atomicity guarantee, working as intended, not something set_config reaches back into. The one place that needs (and gets) special handling is a transaction() block already open in the SAME task that calls set_config — there, the very next statement in that same transaction sees the new value immediately, rather than waiting for the transaction to end and a new one to begin (there is no race to resolve here: the two are sequential code in one task, by construction):

async with engines.session() as session:
    async with transaction():
        await session.set_config("myapp.tenant_id", "acme")
        # this SELECT, in the SAME transaction, already sees it
        rows = await Invoice.where(lambda invoice: invoice.paid).all()

set_config also evicts this session's identity map on a real change: an instance get()-ed under the old scope is never handed back under the new one — the next get() for that primary key refetches instead. A call that doesn't actually change the value (the key already holds it) is a true no-op: no database round trip, no identity-map eviction.

Validation matches engines.session(settings=...) exactly (see there for the full rationale): key and value must be str, and key must be a dotted custom setting name.

Parameters:

Name Type Description Default
key str

Dotted custom Postgres setting name (e.g. "myapp.tenant_id").

required
value str

The setting's new value.

required

Raises:

Type Description
RuntimeError

This session is not open (set_config mutates a live session, so there has to be one), or this session's connection is not Postgres — set_config is a declaration, exactly like settings= at open, so it has to be honourable rather than a silent no-op on a backend with no GUCs. On the rare failure while delivering the change into an already-open transaction, this session's recorded settings and identity map have already committed the change regardless (see the Ferro source for the exact sequencing) — the affected transaction cannot go on to leak an old-scope read, because Postgres aborts a transaction after a failed statement until it is rolled back.

TypeError

key or value is not a str.

ValueError

key is not a dotted custom setting name.

Source code in src/ferro/session.py
async def set_config(self, key: str, value: str) -> None:
    """Mutate this session's settings while it is open.

    Everything you can `settings=` at open, you can also set later, for the
    case where the value isn't known until partway through a request — an
    auth chain that resolves the tenant only after checking a token, say:

        async with engines.session() as session:      # no settings yet
            tenant = await resolve_tenant_from_auth_header(request)
            await session.set_config("myapp.tenant_id", tenant)
            # every query from here on is scoped to `tenant`
            invoices = await Invoice.where(lambda invoice: invoice.paid).all()

    Deep in a call stack, reach the session through `ferro.current_session()`
    rather than threading it through every function signature:

        async def resolve_tenant_and_scope(request) -> None:
            tenant = await resolve_tenant_from_auth_header(request)
            await ferro.current_session().set_config("myapp.tenant_id", tenant)

    **When the new value takes effect, precisely.** Any operation or
    `transaction()` STARTED after `set_config` returns sees the new value
    — this holds for any task, not just the one that called `set_config`,
    because the change is committed before `set_config`'s `await`
    returns. An operation or transaction already in flight *when*
    `set_config` runs keeps running under the scope it started with —
    that is the existing per-operation-atomicity guarantee, working as
    intended, not something `set_config` reaches back into. The one place
    that needs (and gets) special handling is a `transaction()` block
    already open in the SAME task that calls `set_config` — there, the
    very next statement in that same transaction sees the new value
    immediately, rather than waiting for the transaction to end and a new
    one to begin (there is no race to resolve here: the two are
    sequential code in one task, by construction):

        async with engines.session() as session:
            async with transaction():
                await session.set_config("myapp.tenant_id", "acme")
                # this SELECT, in the SAME transaction, already sees it
                rows = await Invoice.where(lambda invoice: invoice.paid).all()

    `set_config` also evicts this session's identity map on a real change:
    an instance `get()`-ed under the old scope is never handed back under
    the new one — the next `get()` for that primary key refetches instead.
    A call that doesn't actually change the value (the key already holds
    it) is a true no-op: no database round trip, no identity-map eviction.

    Validation matches `engines.session(settings=...)` exactly (see there
    for the full rationale): `key` and `value` must be `str`, and `key` must
    be a dotted custom setting name.

    Args:
        key: Dotted custom Postgres setting name (e.g. `"myapp.tenant_id"`).
        value: The setting's new value.

    Raises:
        RuntimeError: This session is not open (`set_config` mutates a live
            session, so there has to be one), or this session's connection
            is not Postgres — `set_config` is a declaration, exactly like
            `settings=` at open, so it has to be honourable rather than a
            silent no-op on a backend with no GUCs. On the rare failure
            while delivering the change into an already-open transaction,
            this session's recorded settings and identity map have
            already committed the change regardless (see the Ferro source
            for the exact sequencing) — the affected transaction cannot go
            on to leak an old-scope read, because Postgres aborts a
            transaction after a failed statement until it is rolled back.
        TypeError: `key` or `value` is not a `str`.
        ValueError: `key` is not a dotted custom setting name.
    """
    if self.session_id is None:
        raise RuntimeError(_SET_CONFIG_NOT_OPEN_MESSAGE)
    if self.connection_name is None:
        # `connection_name` is resolved by `__aenter__` alongside
        # `session_id` (see there), so a live `session_id` should make
        # this unreachable. Raised rather than asserted (`assert` strips
        # under `-O`) because this is a Ferro lifecycle invariant, not an
        # ordinary user mistake.
        raise RuntimeError(_SET_CONFIG_NOT_OPEN_MESSAGE)

    validated = _validated_settings({key: value})
    ((validated_key, validated_value),) = validated.items()

    # Only the one validated pair is sent — Rust merges it against the
    # session's own last-committed settings, inside a lock that
    # serializes the read and the write together, so two sibling tasks
    # setting different keys concurrently can never lose one to a
    # stale-mirror race (see `operations::set_session_config`). The
    # mirror below is always assigned from what Rust actually committed,
    # never computed here — both when this call changed something and
    # when it was a no-op.
    committed = await _core_set_session_config(
        self.session_id,
        self.connection_name,
        validated_key,
        validated_value,
    )
    self.effective_settings = dict(committed)

query(model_cls)

Source code in src/ferro/session.py
def query(self, model_cls):
    from .query import Query

    return Query(model_cls, session=self)

__init__(connection_name=None, session_id=None, settings=None, _token=None, _enter_context=None, _enter_task=None, _close_lock=None)

connect(url, auto_migrate=False, name=None, default=False, pool=None, *, identity_map=True, migrate_updates=False, migrate_destructive=False) async

Establish a connection to the database.

Parameters:

Name Type Description Default
url str

The database connection string (e.g., "sqlite:example.db?mode=rwc").

required
auto_migrate bool

If True, automatically create tables for all registered models. Existing tables are left completely untouched — whatever their shape — unless migrate_updates / migrate_destructive are also set.

False
name str | None

Optional connection name. Omitted connections register as "default".

None
default bool

If True, make this named connection the default for unqualified operations.

False
pool PoolConfig | None

Optional per-connection pool configuration, including settings_delivery — how sessions on this connection deliver their session settings (see PoolConfig).

None
identity_map bool

If True (default), sessions opened on this connection keep an identity map so the same primary key maps to a single Python instance within a session. Identity maps are session-scoped: operations outside a session never cache or dedup instances. If False, loads on this connection return fresh instances even inside a session (lower memory use; no a is b guarantees across loads).

True
migrate_updates bool

If True, additionally update existing tables to match the registered models. Implies auto_migrate. What this covers is capability-relative per backend:

  • Both backends: ALTER TABLE ... ADD COLUMN for model fields missing from the live table, using the same column DDL CREATE TABLE would emit (including single-column indexes and, on Postgres, CHECK constraints and foreign keys). NOT NULL fields need a literal default to backfill existing rows — a json-family object/array (Field(default={}), default_factory=dict) counts; connecting fails with a clear error otherwise.
  • Postgres only: column type changes (ALTER COLUMN ... TYPE ... USING cast) and nullability changes (SET/DROP NOT NULL) when the live column disagrees with the model. Also foreign-key reconciliation: a ferro-owned (fk_-named) constraint whose definition drifts from the declared FK (on_delete, target) is rebuilt (DROP CONSTRAINT + ADD CONSTRAINT), and a declared FK missing entirely from an existing column is added. A drifting constraint ferro does not own is warned about, never altered.
  • SQLite: type/nullability drift cannot be changed in place; ferro emits a UserWarning naming the column and pointing at Alembic. (SQLite's type affinity makes declared-type drift mostly cosmetic.) FK constraints likewise cannot be added or altered on an existing table; any foreign-key drift warns loudly instead of diverging silently.
  • Transactionality: on Postgres each table's migration plan runs inside a single transaction — a mid-plan failure rolls the table back to exactly its pre-migration state. On SQLite, statements apply one at a time; a mid-run failure can leave earlier statements of that table applied (SQLite ALTERs are single-statement operations). For transactional multi-step SQLite migrations, use the Alembic bridge.

After any schema change, the connection pool is refreshed so no cached statement can observe the pre-migration schema.

False
migrate_destructive bool

If True, additionally drop live columns that no longer exist on the model (never whole tables). Implies migrate_updates. Dropping is dependency-aware: explicit indexes covering the column are dropped first; columns that are primary keys or enforced by table constraints fail with a clear error instead.

False

Raises:

Type Description
ValueError

A connection with this name (or a default connection, when name is omitted) is already registered. Use name=... for additional connections or reset_engine() to tear down.

For schema changes beyond these (renames, primary-key changes, complex transforms), use the Alembic bridge — see docs/guide/migrations.md.

Source code in src/ferro/__init__.py
async def connect(
    url: str,
    auto_migrate: bool = False,
    name: str | None = None,
    default: bool = False,
    pool: PoolConfig | None = None,
    *,
    identity_map: bool = True,
    migrate_updates: bool = False,
    migrate_destructive: bool = False,
) -> None:
    """
    Establish a connection to the database.

    Args:
        url: The database connection string (e.g., "sqlite:example.db?mode=rwc").
        auto_migrate: If True, automatically create tables for all registered models.
            Existing tables are left completely untouched — whatever their shape —
            unless ``migrate_updates`` / ``migrate_destructive`` are also set.
        name: Optional connection name. Omitted connections register as "default".
        default: If True, make this named connection the default for unqualified operations.
        pool: Optional per-connection pool configuration, including
            ``settings_delivery`` — how sessions on this connection deliver
            their session settings (see ``PoolConfig``).
        identity_map: If True (default), sessions opened on this connection keep an identity
            map so the same primary key maps to a single Python instance within a session.
            Identity maps are session-scoped: operations outside a session never cache or
            dedup instances. If False, loads on this connection return fresh instances even
            inside a session (lower memory use; no ``a is b`` guarantees across loads).
        migrate_updates: If True, additionally update existing tables to match the
            registered models. Implies ``auto_migrate``. What this covers is
            capability-relative per backend:

            - **Both backends**: ``ALTER TABLE ... ADD COLUMN`` for model fields
              missing from the live table, using the same column DDL ``CREATE TABLE``
              would emit (including single-column indexes and, on Postgres, CHECK
              constraints and foreign keys). NOT NULL fields need a literal default
              to backfill existing rows — a json-family object/array (`Field(default={})`,
              `default_factory=dict`) counts; connecting fails with a clear error
              otherwise.
            - **Postgres only**: column type changes
              (``ALTER COLUMN ... TYPE ... USING`` cast) and nullability changes
              (``SET/DROP NOT NULL``) when the live column disagrees with the model.
              Also foreign-key reconciliation: a ferro-owned (``fk_``-named)
              constraint whose definition drifts from the declared FK
              (``on_delete``, target) is rebuilt (``DROP CONSTRAINT`` +
              ``ADD CONSTRAINT``), and a declared FK missing entirely from an
              existing column is added. A drifting constraint ferro does not
              own is warned about, never altered.
            - **SQLite**: type/nullability drift cannot be changed in place; ferro
              emits a ``UserWarning`` naming the column and pointing at Alembic.
              (SQLite's type affinity makes declared-type drift mostly cosmetic.)
              FK constraints likewise cannot be added or altered on an existing
              table; any foreign-key drift warns loudly instead of diverging
              silently.
            - **Transactionality**: on Postgres each table's migration plan
              runs inside a single transaction — a mid-plan failure rolls the
              table back to exactly its pre-migration state. On SQLite,
              statements apply one at a time; a mid-run failure can leave
              earlier statements of that table applied (SQLite ALTERs are
              single-statement operations). For transactional multi-step
              SQLite migrations, use the Alembic bridge.

            After any schema change, the connection pool is refreshed so no cached
            statement can observe the pre-migration schema.
        migrate_destructive: If True, additionally **drop** live columns that no
            longer exist on the model (never whole tables). Implies
            ``migrate_updates``. Dropping is dependency-aware: explicit indexes
            covering the column are dropped first; columns that are primary keys or
            enforced by table constraints fail with a clear error instead.

    Raises:
        ValueError: A connection with this name (or a default connection,
            when ``name`` is omitted) is already registered. Use ``name=...``
            for additional connections or ``reset_engine()`` to tear down.

    For schema changes beyond these (renames, primary-key changes, complex
    transforms), use the Alembic bridge — see ``docs/guide/migrations.md``.
    """
    _ensure_rust_registration_synced()

    pool_config = pool or PoolConfig()
    await _core_connect(
        url,
        auto_migrate=auto_migrate,
        name=name,
        default=default,
        max_connections=pool_config.max_connections,
        min_connections=pool_config.min_connections,
        settings_delivery=pool_config.settings_delivery,
        identity_map=identity_map,
        migrate_updates=migrate_updates,
        migrate_destructive=migrate_destructive,
    )

PoolConfig

Bases: BaseModel

Connection pool settings for a named Ferro connection.

settings_delivery chooses how sessions on this connection get their session settings (the Postgres GUCs row-level-security policies read) onto the database. The two modes send different SQL for the very same session:

async with engines.session(settings={"myapp.tenant_id": "acme"}):
    invoices = await Invoice.where(lambda invoice: invoice.paid).all()

"transaction" (the default) wraps the query in a transaction of its own and scopes the value to it::

BEGIN
SELECT set_config($1, $2, true)    -- 'myapp.tenant_id', 'acme'
SELECT "invoice".* FROM "invoice" WHERE "paid"
COMMIT

"connection" sets the value once, on a connection it keeps for the session, and then sends your statements bare::

SELECT set_config($1, $2, false), set_config($3, $4, false)
-- 'myapp.tenant_id', 'acme', 'ferro.pinned_keys', 'myapp.tenant_id'
SELECT "invoice".* FROM "invoice" WHERE "paid"
...                                -- every later query, no wrap
SELECT set_config($1, NULL, false), set_config($2, NULL, false)
-- at session close: exactly the keys above, reset

The difference that matters is true vs false — Postgres' is_local flag. true makes the value die with the transaction, which is why the default is safe even behind a transaction pooler like PgBouncer, where consecutive statements can land on different backends. false makes it live for the whole database session, which is only safe if that session belongs to one Ferro session and nobody else — hence the pinned connection.

So settings_delivery="connection" is a promise about your deployment: this pool talks to Postgres directly. Ferro never guesses it. A pooler is invisible to its clients, and guessing wrong means one tenant's value answering another tenant's query.

What you get for the promise: no per-operation wrap at all, so a settings-bearing session costs one extra round trip for its whole life instead of about two per operation outside transaction().

What it costs, and there are four costs worth knowing before you opt in.

One connection per scoped session. A settings-bearing session holds a pool connection from its first operation until it closes, so no more settings-bearing sessions can run at once than the pool has connections. Number max_connections + 1's first query waits for a connection, exactly like any other pool checkout. Size the pool for peak concurrent scoped sessions.

One session, one connection, therefore one thing at a time. Everything a pinned session does is serialized: two sibling tasks sharing the session run one after the other, and while a transaction() block is open a sibling task's operation waits for it rather than running inside it. That is the honest meaning of a session that owns a single connection — the same fact as the cap above, seen from inside one session. Operations in the same task inside a transaction() block are unaffected; they already own the block.

A marker check on release while anyone is pinned. The pool verifies that a connection coming back is not still carrying a session's settings. While no session is pinned this costs nothing at all. While any session on the pool is pinned, every connection release on that pool performs one marker check — including releases by settings-less sessions and by sessionless operations.

Schema changes wait for scoped sessions. connect(migrate_updates=…) and migrate() refresh the pool afterwards, and a refresh cannot finish until every connection comes back — including the pinned ones. Migrating while long-lived tenant sessions are open therefore blocks until they close; Ferro warns, naming how many it is waiting on. Run schema changes before opening tenant-scoped sessions.

Sessions without settings never pin: same statements, same connections, no wrap, no serialization.

Two smaller behaviours worth knowing:

  • A session that opens with no settings and gains them later via set_config has nothing pinned yet, so it pins on its next operation and applies the values then.
  • Closing a session resets its keys to the value the connection started with. For an ordinary custom setting that is the empty string, which is what fail-closed policies rely on. If an operator has set a default with ALTER ROLE ... SET myapp.tenant_id = ... or ALTER DATABASE ... SET ..., resetting brings that value back rather than clearing it — so never configure a tenancy key as a role or database default.
Source code in src/ferro/__init__.py
class PoolConfig(BaseModel):
    """Connection pool settings for a named Ferro connection.

    ``settings_delivery`` chooses how sessions on this connection get their
    session settings (the Postgres GUCs row-level-security policies read) onto
    the database. The two modes send different SQL for the very same session:

        async with engines.session(settings={"myapp.tenant_id": "acme"}):
            invoices = await Invoice.where(lambda invoice: invoice.paid).all()

    ``"transaction"`` (the default) wraps the query in a transaction of its own
    and scopes the value to it::

        BEGIN
        SELECT set_config($1, $2, true)    -- 'myapp.tenant_id', 'acme'
        SELECT "invoice".* FROM "invoice" WHERE "paid"
        COMMIT

    ``"connection"`` sets the value once, on a connection it keeps for the
    session, and then sends your statements bare::

        SELECT set_config($1, $2, false), set_config($3, $4, false)
        -- 'myapp.tenant_id', 'acme', 'ferro.pinned_keys', 'myapp.tenant_id'
        SELECT "invoice".* FROM "invoice" WHERE "paid"
        ...                                -- every later query, no wrap
        SELECT set_config($1, NULL, false), set_config($2, NULL, false)
        -- at session close: exactly the keys above, reset

    The difference that matters is ``true`` vs ``false`` — Postgres'
    ``is_local`` flag. ``true`` makes the value die with the transaction, which
    is why the default is safe even behind a transaction pooler like PgBouncer,
    where consecutive statements can land on different backends. ``false``
    makes it live for the whole database session, which is only safe if that
    session belongs to one Ferro session and nobody else — hence the pinned
    connection.

    So ``settings_delivery="connection"`` is a promise about your deployment:
    **this pool talks to Postgres directly.** Ferro never guesses it. A pooler
    is invisible to its clients, and guessing wrong means one tenant's value
    answering another tenant's query.

    What you get for the promise: no per-operation wrap at all, so a
    settings-bearing session costs one extra round trip for its whole life
    instead of about two per operation outside ``transaction()``.

    What it costs, and there are four costs worth knowing before you opt in.

    **One connection per scoped session.** A settings-bearing session holds a
    pool connection from its first operation until it closes, so **no more
    settings-bearing sessions can run at once than the pool has
    connections**. Number ``max_connections`` + 1's first query waits for a
    connection, exactly like any other pool checkout. Size the pool for peak
    concurrent scoped sessions.

    **One session, one connection, therefore one thing at a time.** Everything
    a pinned session does is serialized: two sibling tasks sharing the session
    run one after the other, and while a ``transaction()`` block is open a
    sibling task's operation waits for it rather than running inside it.
    That is the honest meaning of a session that owns a single connection —
    the same fact as the cap above, seen from inside one session. Operations
    in the same task *inside* a ``transaction()`` block are unaffected; they
    already own the block.

    **A marker check on release while anyone is pinned.** The pool verifies
    that a connection coming back is not still carrying a session's settings.
    While no session is pinned this costs nothing at all. While *any* session
    on the pool is pinned, every connection release on that pool performs one
    marker check — including releases by settings-less sessions and by
    sessionless operations.

    **Schema changes wait for scoped sessions.** ``connect(migrate_updates=…)``
    and ``migrate()`` refresh the pool afterwards, and a refresh cannot finish
    until every connection comes back — including the pinned ones. Migrating
    while long-lived tenant sessions are open therefore blocks until they
    close; Ferro warns, naming how many it is waiting on. Run schema changes
    before opening tenant-scoped sessions.

    Sessions *without* settings never pin: same statements, same connections,
    no wrap, no serialization.

    Two smaller behaviours worth knowing:

    * A session that opens with no ``settings`` and gains them later via
      ``set_config`` has nothing pinned yet, so it pins on its **next
      operation** and applies the values then.
    * Closing a session resets its keys to the value the connection *started*
      with. For an ordinary custom setting that is the empty string, which is
      what fail-closed policies rely on. If an operator has set a default with
      ``ALTER ROLE ... SET myapp.tenant_id = ...`` or ``ALTER DATABASE ... SET
      ...``, resetting brings that value *back* rather than clearing it — so
      never configure a tenancy key as a role or database default.
    """

    model_config = ConfigDict(frozen=True)

    max_connections: int = PydanticField(default=5, ge=1)
    min_connections: int = PydanticField(default=0, ge=0)
    settings_delivery: Literal["transaction", "connection"] = "transaction"

    @model_validator(mode="after")
    def validate(self) -> "PoolConfig":
        if self.min_connections > self.max_connections:
            raise ValueError("min_connections cannot exceed max_connections")
        return self

Attributes

model_config = ConfigDict(frozen=True) class-attribute instance-attribute

max_connections = PydanticField(default=5, ge=1) class-attribute instance-attribute

min_connections = PydanticField(default=0, ge=0) class-attribute instance-attribute

settings_delivery = 'transaction' class-attribute instance-attribute

Functions

validate()

Source code in src/ferro/__init__.py
@model_validator(mode="after")
def validate(self) -> "PoolConfig":
    if self.min_connections > self.max_connections:
        raise ValueError("min_connections cannot exceed max_connections")
    return self

set_default_connection(name)

Source code in src/ferro/_core.pyi
def set_default_connection(name: str) -> None: ...

reset_engine()

Source code in src/ferro/_core.pyi
def reset_engine() -> None: ...

create_tables(using=None) async

Manually create the missing tables for registered models on a connected engine. A table that already exists is left completely untouched; altering existing tables belongs to migrate(updates=True).

Compiles and pushes the current registry SchemaIR to the Rust runtime before delegating to the Rust create entrypoint, so a model defined after connect() (and thus absent from the connect-time snapshot) is still created. The runtime emits each CREATE TABLE from this SchemaIR via the shared emitter.

Parameters:

Name Type Description Default
using

Named connection to create tables on, or None for the default.

None
Source code in src/ferro/__init__.py
async def create_tables(using=None):
    """
    Manually create the *missing* tables for registered models on a connected
    engine. A table that already exists is left completely untouched; altering
    existing tables belongs to ``migrate(updates=True)``.

    Compiles and pushes the current registry SchemaIR to the Rust runtime
    before delegating to the Rust create entrypoint, so a model defined after
    ``connect()`` (and thus absent from the connect-time snapshot) is still
    created. The runtime emits each ``CREATE TABLE`` from this SchemaIR via the
    shared emitter.

    Args:
        using: Named connection to create tables on, or None for the default.
    """
    _ensure_rust_registration_synced()
    return await _core_create_tables(using=using)

migrate(using=None, updates=True, destructive=False) async

Manually run the auto-migrate pass against a connected engine.

Compiles and pushes the current registry SchemaIR to the Rust runtime, then delegates to the Rust migrate entrypoint.

Parameters:

Name Type Description Default
using

Named connection to migrate, or None for the default.

None
updates

If True (default), add missing columns and reconcile type, nullability, and foreign-key definition drift (see connect).

True
destructive

If True, also drop live columns absent from the model. Implies updates.

False
Source code in src/ferro/__init__.py
async def migrate(using=None, updates=True, destructive=False):
    """
    Manually run the auto-migrate pass against a connected engine.

    Compiles and pushes the current registry SchemaIR to the Rust runtime,
    then delegates to the Rust migrate entrypoint.

    Args:
        using: Named connection to migrate, or None for the default.
        updates: If True (default), add missing columns and reconcile
            type, nullability, and foreign-key definition drift (see ``connect``).
        destructive: If True, also drop live columns absent from the model. Implies ``updates``.
    """
    _ensure_rust_registration_synced()
    return await _core_migrate(using=using, updates=updates, destructive=destructive)

clear_registry()

Reset the compiled/registered schema state.

Delegates to the Rust core (which clears the Rust model registry and the pushed SchemaIR modelset) and additionally clears the Python join-table registry. That registry must be reset here because connect/create_tables/migrate compile the full registry via compile_registry_schema_ir(): a join table left behind by a prior run would be re-created with foreign keys to tables that no longer exist — tolerated by SQLite but rejected by Postgres (relation ... does not exist). (#153)

The Python model registry is intentionally not cleared here: clearing the Rust registry while keeping the declared Python models is what allows cold re-hydration after reset_engine (see tests/test_enum_cold_hydration.py). Callers that want a full Python-side reset use REGISTRY.reset_for_test().

Each purged join table's compiled SchemaIR envelope is evicted with it — Registry.clear_join_tables owns that agreement (#153): a lingering join envelope would let a future assemble step resurrect the stale join table.

Source code in src/ferro/__init__.py
def clear_registry() -> None:
    """Reset the compiled/registered schema state.

    Delegates to the Rust core (which clears the Rust model registry and the
    pushed SchemaIR modelset) and additionally clears the Python **join-table
    registry**. That registry must be reset here because
    ``connect``/``create_tables``/``migrate`` compile the *full* registry via
    ``compile_registry_schema_ir()``: a join table left behind by a prior run
    would be re-created with foreign keys to tables that no longer exist —
    tolerated by SQLite but rejected by Postgres (``relation ... does not
    exist``). (#153)

    The Python model registry is intentionally **not** cleared here: clearing
    the Rust registry while keeping the declared Python models is what allows
    cold re-hydration after ``reset_engine`` (see
    ``tests/test_enum_cold_hydration.py``). Callers that want a full
    Python-side reset use ``REGISTRY.reset_for_test()``.

    Each purged join table's compiled SchemaIR envelope is evicted with it —
    ``Registry.clear_join_tables`` owns that agreement (#153): a lingering
    join envelope would let a future assemble step resurrect the stale join
    table.
    """
    _core_clear_registry()
    from .registry import REGISTRY

    REGISTRY.clear_join_tables()

evict_instance(model, pk, *, using=None, session=None)

Remove one instance from the active scope's identity map.

model is a model class, its qualified identity, or an unambiguous bare class name (ambiguity raises with the candidates listed).

Public wrapper around the FFI evict_instance (FF-D D3): resolves the route once via resolve_operation_scope, then passes it through. Model instance methods (save/delete/refresh) call the FFI symbol directly with their already-resolved route instead of going through this wrapper, so a route is never resolved twice for one operation.

Source code in src/ferro/models.py
def evict_instance(
    model: "type[Model] | str",
    pk: str,
    *,
    using: str | None = None,
    session: "Session | None" = None,
) -> None:
    """Remove one instance from the active scope's identity map.

    ``model`` is a model class, its qualified identity, or an unambiguous
    bare class name (ambiguity raises with the candidates listed).

    Public wrapper around the FFI `evict_instance` (FF-D D3): resolves the
    route once via `resolve_operation_scope`, then passes it through. Model
    instance methods (`save`/`delete`/`refresh`) call the FFI symbol
    directly with their already-resolved route instead of going through this
    wrapper, so a route is never resolved twice for one operation.
    """
    from .registry import REGISTRY

    model_cls = REGISTRY.resolve_reference(model) if isinstance(model, str) else model
    route = resolve_operation_scope(using=using, session=session)
    _core_evict_instance(model_cls.__ferro_identity__, pk, route)

version()

Source code in src/ferro/_core.pyi
def version() -> str: ...