{"id":22114002,"url":"https://github.com/iloveitaly/activemodel","last_synced_at":"2026-05-05T15:01:34.842Z","repository":{"id":263057993,"uuid":"886058144","full_name":"iloveitaly/activemodel","owner":"iloveitaly","description":"SQLModel with developer ergonomics. Make SQLModel act like ActiveRecord.","archived":false,"fork":false,"pushed_at":"2026-04-26T12:01:00.000Z","size":43200,"stargazers_count":19,"open_issues_count":7,"forks_count":1,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-04-26T14:04:37.457Z","etag":null,"topics":["activemodel","activerecord","orm","sqlalchemy","sqlmodel"],"latest_commit_sha":null,"homepage":"https://iloveitaly.github.io/activemodel/","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/iloveitaly.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":["iloveitaly"]}},"created_at":"2024-11-10T04:31:09.000Z","updated_at":"2026-04-26T12:00:26.000Z","dependencies_parsed_at":"2024-12-13T13:35:19.142Z","dependency_job_id":"09ec6bb9-294f-4ef4-8418-9afe0985a09e","html_url":"https://github.com/iloveitaly/activemodel","commit_stats":null,"previous_names":["iloveitaly/activemodel"],"tags_count":21,"template":false,"template_full_name":null,"purl":"pkg:github/iloveitaly/activemodel","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iloveitaly%2Factivemodel","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iloveitaly%2Factivemodel/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iloveitaly%2Factivemodel/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iloveitaly%2Factivemodel/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/iloveitaly","download_url":"https://codeload.github.com/iloveitaly/activemodel/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iloveitaly%2Factivemodel/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32654618,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-05T11:29:49.557Z","status":"ssl_error","status_checked_at":"2026-05-05T11:29:48.587Z","response_time":54,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activemodel","activerecord","orm","sqlalchemy","sqlmodel"],"created_at":"2024-12-01T11:08:17.397Z","updated_at":"2026-05-05T15:01:34.833Z","avatar_url":"https://github.com/iloveitaly.png","language":"Python","funding_links":["https://github.com/sponsors/iloveitaly"],"categories":[],"sub_categories":[],"readme":"[![Release Notes](https://img.shields.io/github/release/iloveitaly/activemodel)](https://github.com/iloveitaly/activemodel/releases)\n[![Downloads](https://static.pepy.tech/badge/activemodel/month)](https://pepy.tech/project/activemodel)\n![GitHub CI Status](https://github.com/iloveitaly/activemodel/actions/workflows/build_and_publish.yml/badge.svg)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\n# ActiveModel: ORM Wrapper for SQLModel\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/_static/logo.png\" alt=\"ActiveModel Logo\" width=\"600\"/\u003e\n\u003c/p\u003e\n\nNo, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a ActiveRecord-like interface.\n\nSQLModel is *not* an ORM. It's a SQL query builder and a schema definition tool. This drives me nuts because the developer ergonomics are terrible because of this.\n\nThis package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:\n\n* **ActiveRecord-style Query \u0026 Persistence API**: Fluent methods like `save()`, `where()`, `find_or_create_by()`, and `upsert()` for intuitive database operations.\n* **Implicit Session Management**: Automatically handles database sessions, eliminating boilerplate and making database interactions feel \"magic\".\n* **Stripe-style IDs (TypeID)**: Native support for type-safe, prefixed, and sortable UUIDs with a built-in `TypeIDPrimaryKey`.\n* **whenever datetime types**: Optional integration for `whenever.Instant`, `whenever.PlainDateTime`, and `whenever.ZonedDateTime` as first-class field annotations.\n* **Timestamp Column Mixins**: Standard `created_at` and `updated_at` tracking out of the box.\n* **Lifecycle Hooks**: Rails-style callbacks like `before_save`, `after_create`, and `around_delete`.\n* **Automatic DB Comments**: Syncs class and field-level docstrings directly to database table and column comments for better self-documentation.\n* **Soft Deletion**: Easily mark records as deleted with a `deleted_at` timestamp using the `SoftDeletionMixin`.\n* **Smart Table \u0026 Constraint Naming**: Consistent snake_case table names and standardized naming conventions for indexes and constraints.\n* **Pytest Integration**: Built-in fixtures, database cleanup strategies, and factory integration for robust testing.\n\n\u003e [!TIP]\n\u003e This documentation is pretty bad. The tests and docstrs on code are the best way to learn how to use this. \n\n## Installation\n\n```bash\nuv add activemodel\n```\n\n## Getting Started\n\nFirst, setup your DB:\n\n```python\nimport activemodel\nactivemodel.init(\"sqlite:///database.db\")\n```\n\nCreate models:\n\n```python\nfrom activemodel import BaseModel\nfrom activemodel.mixins import TimestampsMixin\nfrom activemodel.types.typeid import TypeIDPrimaryKey\nfrom typeid import TypeID\n\nclass User(BaseModel, TimestampsMixin, table=True):\n    id: TypeID = TypeIDPrimaryKey(\"user\")\n    a_field: str\n```\n\nYou'll need to create the models in the DB. Alembic is the best way to do it, but you can cheat as well:\n\n```python\nfrom sqlmodel import SQLModel\n\nSQLModel.metadata.create_all(get_engine())\n\n# now you can create a user! without managing sessions!\nUser(a_field=\"a\").save()\n```\n\nMaybe you like JSON:\n\n```python\nfrom sqlalchemy.dialects.postgresql import JSONB\nfrom pydantic import BaseModel as PydanticBaseModel\n\nfrom activemodel import BaseModel\nfrom activemodel.mixins import PydanticJSONMixin, TimestampsMixin\nfrom activemodel.types.typeid import TypeIDPrimaryKey\nfrom typeid import TypeID\n\nclass SubObject(PydanticBaseModel):\n    name: str\n    value: int\n\nclass User(BaseModel, TimestampsMixin, PydanticJSONMixin, table=True):\n    id: TypeID = TypeIDPrimaryKey(\"user\")\n    list_field: list[SubObject] = Field(sa_type=JSONB)\n    profile: SubObject = Field(sa_type=JSONB)\n```\n\n`PydanticJSONMixin` automatically rehydrates raw JSON from the database back into the annotated Pydantic types on load and refresh.\n\nIt also tracks in-place mutations automatically — no need to call `flag_modified` manually:\n\n```python\nuser = User.one(\"user_123\")\n\n# scalar field: mutating a nested attribute is detected automatically\nuser.profile.name = \"new name\"\nuser.save()  # persists without flag_modified\n\n# list field: all mutation methods trigger dirty tracking\nuser.list_field.append(SubObject(name=\"new\", value=1))\nuser.list_field[0].name = \"updated\"\nuser.save()  # persists\n```\n\nSupported field annotations:\n\n- `SubModel`\n- `SubModel | None`\n- `list[SubModel]`\n- `list[SubModel] | None`\n\nRaw `dict`, `dict[...]`, `list[dict]`, and top-level primitive list fields such as\n`list[str]` and `list[int]` stay as plain Python containers on\nload and refresh, but their in-place mutations are also tracked automatically.\n\nAmbiguous unions like `SubModel | dict | None` are left as raw JSON since there is no unambiguous way to rehydrate them.\n\nAs with standard Pydantic, a raw `dict` will never compare equal to a model instance — use `.model_dump()` if you need dict comparison.\n\nYou'll probably want to query the model. Look ma, no sessions!\n\n```python\nUser.where(id=\"user_123\").all()\n\n# or, even better, for this case\nUser.one(\"user_123\")\n```\n\nMagically creating sessions for DB operations is one of the main problems this project tackles. Even better, you can set\na single session object to be used for all DB operations. This is helpful for DB transactions, [specifically rolling back\nDB operations on each test.](#pytest)\n\n## Usage\n\n### Lifecycle Hooks\n\n`BaseModel` supports a small Rails-style lifecycle hook system.\n\nThe implemented hooks today are:\n\n* Create/update: `before_create`, `after_create`, `before_update`, `after_update`, `before_save`, `after_save`, `around_save`\n* Delete: `before_delete`, `after_delete`, `around_delete`\n* Read: `after_find`, `after_initialize`\n\nHook methods are optional. If a method with one of those names exists on the model, ActiveModel will call it at the appropriate time.\n\n```python\nfrom contextlib import contextmanager\n\nfrom activemodel import BaseModel\n\n\nclass User(BaseModel, table=True):\n    id: int | None = Field(default=None, primary_key=True)\n    email: str\n\n    def before_save(self):\n        self.email = self.email.strip().lower()\n\n    def after_find(self):\n        print(f\"loaded user {self.id}\")\n\n    def after_initialize(self):\n        print(f\"initialized user {self.id}\")\n\n    @contextmanager\n    def around_save(self):\n        print(\"before save\")\n        yield\n        print(\"after save\")\n```\n\nSome important semantics:\n\n* `after_initialize` runs on plain construction, so `User(email=\"a@example.com\")` will trigger it even before the record is saved.\n* Database-backed finder/query loads run `after_find` and then `after_initialize`.\n* `after_find` is not called for plain construction.\n* `find_or_initialize_by()` follows the Rails-style split: the existing-record path runs `after_find` then `after_initialize`, while the new-instance path only runs `after_initialize`.\n* `around_save` and `around_delete` must be context managers.\n\nCurrent ordering is:\n\n* Create: `before_create -\u003e before_save -\u003e around_save -\u003e after_create -\u003e after_save`\n* Update: `before_update -\u003e before_save -\u003e around_save -\u003e after_update -\u003e after_save`\n* Delete: `before_delete -\u003e around_delete -\u003e after_delete`\n* DB load: `after_find -\u003e after_initialize`\n* Plain construction: `after_initialize`\n\nThere is one important scope limit to know about:\n\n* `refresh()` does **not** currently replay Rails-style read callbacks. It refreshes the object from the database, but it does not currently trigger `after_find` / `after_initialize` the way Rails `reload` effectively does.\n\nAlso note that `after_find` / `after_initialize` only run for model instances. Lower-level query paths that return `None`, counts, scalars, or raw SQLAlchemy result objects are outside that contract.\n\n### Integrating Alembic\n\nDetailed instructions on how to integrate Alembic into your project can be found in the [Alembic Integration](https://iloveitaly.github.io/activemodel/alembic.html) documentation.\n\n### Query Wrapper\n\nThis tool is added to all `BaseModel`s and makes it easy to write SQL queries. Some examples:\n\n\n\n### Easy Database Sessions\n\nI hate the idea f\n\n* Behavior should be intuitive and easy to understand. If you run `save()`, it should save, not stick the save in a transaction.\n* Don't worry about dead sessions. This makes it easy to lazy-load computed properties and largely eliminates the need to think about database sessions.\n\nThere are a couple of thorny problems we need to solve for here:\n\n* In-memory fastapi servers are not the same as a uvicorn server, which is threaded *and* uses some sort of threadpool model for handling async requests. I don't claim to understand the entire implementation. For global DB session state (a) we can't use global variables (b) we can't use thread-local variables.iset\n*\n\nhttps://github.com/tomwojcik/starlette-context\n\n### Example SQLAlchemy Queries\n\n* Conditional: `Scrape.select().where(Scrape.id \u003c last_scraped.id).all()`\n* Equality: `MenuItem.select().where(MenuItem.menu_id == menu.id).all()`\n* `IN` example: `CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()`\n* Compound where query: `User.where((User.last_active_at != None) \u0026 (User.last_active_at \u003e last_24_hours)).count()`\n* How to select a field in a JSONB column: `str(HostScreeningOrder.form_data[\"email\"].as_string())`\n* JSONB where clause: `Screening.where(Screening.theater_location['name'].astext.ilike('%AMC%'))`\n\n### SQLModel Internals\n\nSQLModel \u0026 SQLAlchemy are tricky. Here are some useful internal tricks:\n\n* `__sqlmodel_relationships__` is where any `RelationshipInfo` objects are stored. This is used to generate relationship fields on the object.\n* `inspect(type(self)).relationships['distribution']` to inspect a specific generated relationship object\n* `ModelClass.relationship_name.property.local_columns`\n* Get cached fields from a model `object_state(instance).dict.get(field_name)`\n* Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`\n* Is a model dirty `instance_state(instance).modified`\n* `select(Table).outerjoin??` won't work in a ipython session, but `Table.__table__.outerjoin??` will. `__table__` is a reference to the underlying SQLAlchemy table record.\n* `get_engine().pool.stats()` is helpful for inspecting connection pools and limits\\\n\n### whenever Datetime Types\n\n[whenever](https://github.com/ariebovenberg/whenever) is a modern, type-safe datetime library for Python. Install the optional integration:\n\n```bash\nuv add activemodel[extras]\n```\n\nOnce installed, you can use `whenever.Instant`, `whenever.PlainDateTime`, and `whenever.ZonedDateTime` directly as field type annotations — no `sa_type=` needed:\n\n```python\nfrom whenever import Instant, PlainDateTime, ZonedDateTime\nfrom activemodel import BaseModel\nfrom activemodel.types.typeid import TypeIDPrimaryKey\nfrom typeid import TypeID\n\nclass Event(BaseModel, table=True):\n    id: TypeID = TypeIDPrimaryKey(\"event\")\n    triggered_at: Instant | None = None\n    local_time: PlainDateTime | None = None\n    scheduled_at: ZonedDateTime | None = None\n```\n\n`PlainDateTime` is stored as a naive `TIMESTAMP` / `DATETIME` value and round-trips as a local date-time without timezone information. On databases with native timezone-aware timestamp support, `Instant` and `ZonedDateTime` are stored as `TIMESTAMP WITH TIME ZONE` values. `Instant` round-trips exactly. `ZonedDateTime` preserves the UTC moment but not the original IANA timezone name (the DB stores the UTC offset at write time).\n\nSQLite does not store timezone information in its datetime columns. If you use `whenever` fields with SQLite, make sure the environment writing and reading those values is configured with the server timezone semantics you expect. In practice, that means SQLite is best suited for local development or test scenarios where you control the process timezone behavior.\n\nThey also work in plain Pydantic response models without any extra setup, since `whenever` has built-in Pydantic v2 support:\n\n```python\nfrom pydantic import BaseModel as PydanticBaseModel\nfrom whenever import Instant\n\nclass EventResponse(PydanticBaseModel):\n    id: str\n    triggered_at: Instant\n```\n\n### TypeID\n\nI'm a massive fan of Stripe-style prefixed UUIDs. [There's an excellent project](https://github.com/jetify-com/typeid)\nthat defined a clear spec for these IDs. I've used the python implementation of this spec and developed a clean integration\nwith SQLModel that plays well with fastapi as well.\n\nHere's an example of defining a relationship:\n\n```python\nimport uuid\n\nfrom activemodel import BaseModel\nfrom activemodel.types import TypeIDType\nfrom activemodel.types.typeid import TypeIDPrimaryKey\nfrom sqlmodel import Field, Relationship\nfrom typeid import TypeID\n\nfrom .patient import Patient\n\nclass Appointment(BaseModel, table=True):\n    id: TypeID = TypeIDPrimaryKey(\"appointment\")\n    # `foreign_key` is a activemodel method to generate the right `Field` for the relationship\n    # TypeIDType is really important here for fastapi serialization\n    doctor_id: TypeIDType = Doctor.foreign_key()\n    doctor: Doctor = Relationship()\n```\n\nHere's how to get the prefix associated with a given field:\n\n```python\nmodel_class.__model__.model_fields[\"field_name\"].sa_column.type.prefix\n```\n\n## Limitations\n\n### Validation\n\nSQLModel does not currently support pydantic validations (when `table=True`). This is very surprising, but is actually the intended functionality:\n\n* https://github.com/fastapi/sqlmodel/discussions/897\n* https://github.com/fastapi/sqlmodel/pull/1041\n* https://github.com/fastapi/sqlmodel/issues/453\n* https://github.com/fastapi/sqlmodel/issues/52#issuecomment-1311987732\n\nFor validation:\n\n* When consuming API data, use a separate shadow model to validate the data with `table=False` and then inherit from that model in a model with `table=True`.\n* When validating ORM data, use SQL Alchemy hooks.\n\n\u003c!--\n\nThis looks neat\nhttps://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L155\n        schema_extra={\n            'pattern': r'^[a-z0-9_\\-\\.]+\\@[a-z0-9_\\-\\.]+\\.[a-z\\.]+$'\n        },\n\nextra constraints\n\nhttps://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L424C1-L426C6\n--\u003e\n\n## Development\n\nWatch out for subtle differences across pydantic versions. There's some sneaky type inspection stuff in `PydanticJSONMixin`\nthat will break in subtle ways if the python, pydantic, etc versions don't match.\n\n```python\nimport pydantic\nprint(pydantic.VERSION)\nimport sys\nprint(sys.version)\n```\n\n## Related Projects\n\n* https://github.com/woofz/sqlmodel-basecrud\n* https://github.com/0xthiagomartins/sqlmodel-controller\n* https://github.com/litestar-org/advanced-alchemy\n* https://github.com/dialoguemd/fastapi-sqla\n\n## Inspiration\n\n* https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg\n* [Albemic instructions](https://github.com/fastapi/sqlmodel/pull/899/files)\n* https://github.com/fastapiutils/fastapi-utils/\n* https://github.com/fastapi/full-stack-fastapi-template\n* https://github.com/DarylStark/my_data/\n* https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app\n\n## Upstream Changes\n\n- [ ] https://github.com/fastapi/sqlmodel/pull/1293\n\n---\n\n*This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Filoveitaly%2Factivemodel","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Filoveitaly%2Factivemodel","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Filoveitaly%2Factivemodel/lists"}