Skip to main content

Eloquent-inspired ORM for Python with Pydantic and FastAPI support

Project description

Pyloquent

Eloquent-inspired ORM for Python with Pydantic integration

Python Version License

Pyloquent brings the elegant ORM patterns from Laravel's Eloquent to Python, with full async/await support, Pydantic validation, and FastAPI integration.

Features

  • ๐Ÿš€ Async/Await First - Built from the ground up for async Python
  • โœ… Pydantic Integration - Full validation and type safety
  • ๐Ÿ”— Rich Relationships - HasOne, HasMany, BelongsTo, BelongsToMany, HasOneThrough, HasManyThrough, MorphOne, MorphMany, MorphTo, MorphToMany, MorphedByMany
  • ๐Ÿ—ƒ๏ธ Query Builder - Fluent, chainable query interface with 60+ methods
  • ๐Ÿ’พ Multiple Drivers - SQLite, PostgreSQL, MySQL, Cloudflare D1
  • โšก Query Caching - Memory, File, and Redis cache stores
  • ๐Ÿ“ Migrations - Full migration system with CLI
  • ๐Ÿงช Testing Support - Model factories and comprehensive test utilities
  • ๐ŸŽฏ FastAPI Ready - Lifespan context manager support
  • ๐Ÿ”„ Soft Deletes - Built-in soft delete with full event support
  • ๐Ÿ“ก Events/Observers - Full model lifecycle hooks (retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored)
  • ๐Ÿ”’ Row Locking - lock_for_update() / for_share() support
  • ๐Ÿ“ฆ Rich Collections - 60+ collection methods for data manipulation
  • ๐Ÿ” Upsert & Bulk Ops - upsert(), insert_or_ignore(), update_or_insert(), increment(), decrement()
  • ๐Ÿ” Subquery Support - where_exists(), where_not_exists() with callable subqueries
  • ๐Ÿ“„ Pagination - paginate(), simple_paginate(), cursor() streaming

Quick Start

pip install pyloquent
from typing import Optional
from pyloquent import Model, ConnectionManager

# Configure connection
manager = ConnectionManager()
manager.add_connection('default', {
    'driver': 'sqlite',
    'database': 'app.db',
}, default=True)

await manager.connect()

# Define model
class User(Model):
    __table__ = 'users'
    __fillable__ = ['name', 'email']
    
    id: Optional[int] = None
    name: str
    email: str

# Create
user = await User.create({'name': 'John', 'email': 'john@example.com'})

# Read
user = await User.find(1)
users = await User.where('active', True).order_by('name').get()

# Update
user.name = 'Jane'
await user.save()

# Delete
await user.delete()

Documentation

๐Ÿ“– Full Documentation - Comprehensive guide covering:

  • Installation & Configuration
  • Models & CRUD Operations
  • Query Builder (60+ methods)
  • All Relationship Types
  • Collections (60+ methods)
  • Mutators & Casting
  • Query Scopes
  • Events & Observers
  • Query Caching
  • Database Migrations
  • Testing with Factories
  • Cloudflare D1
  • FastAPI Integration

Quick Examples

Relationships

class Country(Model):
    def posts(self):
        return self.has_many_through(Post, User)   # HasManyThrough

    def latest_profile(self):
        return self.has_one_through(Profile, User)  # HasOneThrough

class Post(Model):
    def tags(self):
        return self.morph_to_many(Tag, 'taggable') # MorphToMany

class Tag(Model):
    def posts(self):
        return self.morphed_by_many(Post, 'taggable') # MorphedByMany

# Polymorphic many-to-many
await post.tags().attach([tag1.id, tag2.id])
await post.tags().sync([tag2.id, tag3.id])
tags = await post.tags().get()

Query Builder โ€” New Methods

# Atomic increment / decrement
await User.where('id', 1).increment('score', 10)
await user.decrement('credits', 5)

# Upsert (insert or update on conflict)
await User.query.upsert(
    [{'email': 'alice@example.com', 'name': 'Alice', 'score': 100}],
    unique_by=['email'],
    update_columns=['name', 'score'],
)

# Insert or ignore duplicates silently
await User.query.insert_or_ignore([{'email': 'existing@example.com', 'name': 'Dup'}])

# Update or insert
await User.query.update_or_insert({'email': 'bob@example.com'}, {'name': 'Bob'})

# WHERE EXISTS subquery
users = await User.where_exists(
    lambda q: q.from_('orders').where_raw('"orders"."user_id" = "users"."id"')
).get()

# Row locking (ignored on SQLite, honoured on PostgreSQL/MySQL)
user = await User.where('id', 1).lock_for_update().first()

# Conditional clauses
results = await User.query \
    .when(search_term, lambda q: q.where('name', 'like', f'%{search_term}%')) \
    .unless(include_inactive, lambda q: q.where('active', True)) \
    .get()

# Paginate with metadata
page = await User.paginate(per_page=15, page=2)
# {'data': [...], 'total': 120, 'per_page': 15, 'current_page': 2, 'last_page': 8}

# Cursor streaming (memory-efficient)
async for user in User.query.cursor():
    process(user)

# Debug raw SQL
print(User.where('active', True).where('score', '>', 50).to_raw_sql())
# SELECT * FROM "users" WHERE "active" = 1 AND "score" > 50

Model Instance Methods

user = await User.find(1)

# Atomic update
await user.increment('login_count')

# Instance update
await user.update({'name': 'New Name'})

# Replicate with overrides
replica = await user.replicate({'email': 'copy@example.com'})

# Serialisation control
user.make_visible('secret_field')
user.make_hidden('internal_field')
d = user.to_dict()   # respects __hidden__ / make_visible / make_hidden

# Change tracking
await user.save()
print(user.was_changed('name'))   # True / False
print(user.get_changes())         # {'name': 'New Name'}

# Batch delete
await User.destroy(1, 2, 3)

# Find many
users = await User.find_many([1, 2, 3])

Soft Deletes with Events

from pyloquent import Model, SoftDeletes

class Post(Model, SoftDeletes):
    __table__ = 'posts'
    deleted_at: Optional[datetime] = None

# Register lifecycle hooks
Post.on('deleting',  lambda m: print(f'Deleting {m.id}'))
Post.on('deleted',   lambda m: print(f'Deleted  {m.id}'))
Post.on('restoring', lambda m: print(f'Restoring {m.id}'))
Post.on('restored',  lambda m: print(f'Restored  {m.id}'))

# Abort soft delete by returning False from 'deleting'
Post.on('deleting', lambda m: False if m.is_locked else None)

await post.delete()        # soft delete  โ†’ fires deleting / deleted
await post.restore()       # undo         โ†’ fires restoring / restored
await post.force_delete()  # permanent

posts = await Post.with_trashed().get()
trashed = await Post.only_trashed().get()

Collections โ€” 60+ Methods

users = await User.all()

# Presence
users.is_empty()
users.contains('name', 'Alice')
users.first_where('age', '>=', 18)
users.sole()            # raises if not exactly one

# Set operations
users.diff(other)
users.intersect(other)
users.unique('email')
users.duplicates('domain')

# Grouping / splitting
groups = users.group_by('country')
active, inactive = users.partition(lambda u: u.is_active)
chunks = users.split(3)
removed = users.splice(2, 1, [replacement])

# Transformations
users.flat_map(lambda u: u.roles)
users.map_with_keys(lambda u: (u.id, u.name))
users.map_into(UserDTO)
users.key_by('id')

# Statistics
users.reduce(lambda acc, u: acc + u.score, 0)
users.count_by(lambda u: u.country)
users.median('age')
users.mode('country')

# Pipelines
result = users.pipe(lambda c: c.filter(...).map(...))
users.tap(lambda c: logger.debug(f'{c.count()} users'))
filtered = users.when(flag, lambda c: c.filter(pred))

# Mutation
users.push(new_user)
users.prepend(admin)
users.shuffle()
sample = users.random(5)
users.pad(10, placeholder_user)

# Serialisation
users.to_json()
users.only('id', 'name', 'email')
users.except_('password')
users.where_not_in('status', ['banned', 'pending'])
users.take(10)
users.skip(20)
users.take_while(lambda u: u.score > 0)
users.sort_by('name')
users.sort_by_desc('created_at')

Events & Observers

class User(Model):
    @classmethod
    def boot(cls):
        super().boot()
        cls.on('retrieved', lambda u: audit_log('read', u))
        cls.on('creating',  lambda u: setattr(u, 'slug', slugify(u.name)))
        cls.on('deleting',  lambda u: False if u.is_admin else None)  # abort

Migrations

pyloquent make:migration create_users_table --create
pyloquent migrate
pyloquent migrate:rollback
pyloquent migrate:fresh

FastAPI Integration

from contextlib import asynccontextmanager
from fastapi import FastAPI
from pyloquent import ConnectionManager

manager = ConnectionManager()
manager.add_connection('default', {'driver': 'sqlite', 'database': 'app.db'}, default=True)

@asynccontextmanager
async def lifespan(app: FastAPI):
    await manager.connect()
    yield
    await manager.disconnect()

app = FastAPI(lifespan=lifespan)

@app.get('/users')
async def list_users(page: int = 1):
    return await User.paginate(per_page=20, page=page)

Available Drivers

Driver Package Status
SQLite Built-in (aiosqlite) โœ… Ready
PostgreSQL asyncpg โœ… Ready
MySQL aiomysql โœ… Ready
Cloudflare D1 HTTP API โœ… Ready

CLI Commands

pyloquent make:model User
pyloquent make:model User --migration
pyloquent make:migration create_users_table --create
pyloquent migrate
pyloquent migrate:rollback
pyloquent migrate:status
pyloquent migrate:fresh

Why Pyloquent?

Why use an ORM at all?

If you have ever built a Python application that talks to a database, you have probably written something like this:

cursor.execute(
    "SELECT * FROM users WHERE active = ? AND created_at > ? ORDER BY name",
    (True, cutoff_date)
)
rows = cursor.fetchall()
users = [{"id": r[0], "name": r[1], "email": r[2]} for r in rows]

That works โ€” until it doesn't. As soon as you need to join another table, filter on a related record, or serialise the result to JSON, the SQL strings grow, the column-index magic breaks, and the logic scatters across the codebase. An ORM solves this by letting you think in Python objects and relationships rather than SQL strings:

users = await User.where('active', True) \
                  .where('created_at', '>', cutoff_date) \
                  .order_by('name') \
                  .get()

Concrete benefits that matter day-to-day:

  • No raw string SQL for standard queries โ€” inserts, updates, deletes, joins, and aggregates are method calls with IDE autocomplete and no typos.
  • Automatic SQL-injection protection โ€” values are always parameterised; you never concatenate user input into a query string.
  • Relationships as first-class objects โ€” instead of writing a JOIN, you define def posts(self): return self.has_many(Post) once, then call await user.posts().get() anywhere. Eager loading (with_) avoids N+1 queries automatically.
  • Validation at the model layer โ€” field types, default values, and constraints live alongside the data, not scattered across form handlers.
  • Database portability โ€” switching from SQLite in development to PostgreSQL in production is a config change, not a rewrite.
  • Change tracking and dirty checking โ€” user.was_changed('email') tells you what mutated between load and save without manual diffing.
  • A single place for business rules โ€” lifecycle events (creating, updating, deleting) let you enforce invariants (slugify a name on create, block deletion of locked records) without touching every call-site.

Why Pyloquent specifically?

Python already has capable ORMs. Here is where Pyloquent fits in:

Pyloquent SQLAlchemy Django ORM Tortoise ORM
Async-first โœ… native โš  async extension โš  ASGI layer โœ… native
Pydantic v2 models โœ… built-in โŒ separate step โŒ separate step โŒ separate step
Fluent chainable builder โœ… โš  verbose Core API โœ… โœ…
Framework-agnostic โœ… โœ… โŒ Django only โœ…
Polymorphic relations โœ… full set โš  manual โš  limited โš  limited
Built-in soft deletes โœ… โŒ โŒ โŒ
Model events/observers โœ… โš  โœ… signals โš 
Query caching โœ… โŒ โŒ โŒ

You do not need to know Laravel to benefit from Pyloquent. The design choices simply happen to be good ones:

  • Pydantic v2 as the model layer means your ORM models are your API schemas. Define a User model once and use it for database I/O, request validation, and response serialisation โ€” no separate UserSchema class.
  • Async-native from the start means it works naturally with FastAPI, Starlette, Litestar, and any other async framework without thread-pool workarounds or synchronous blocking.
  • Active Record pattern keeps things simple: the model knows how to save and load itself. There is no separate session, unit-of-work, or mapper to configure โ€” you call await User.find(1) and get a User back.
  • A fluent query builder over raw SQL means you get the full power of SQL (window functions, subqueries, CTEs) via method chaining when you need it, without giving up readability.
  • Everything included โ€” soft deletes, model events, query caching, database migrations, model factories, and a CLI โ€” so you are not stitching together five separate packages.

In short: if you are building an async Python application and want your database code to be readable, safe, and testable without learning a framework or writing boilerplate, Pyloquent is worth a look.

Quick comparison

# SQLAlchemy (Core + ORM)
from sqlalchemy import select
stmt = select(User).where(User.active == True).order_by(User.name)
async with async_session() as session:
    result = await session.execute(stmt)
    users = result.scalars().all()

# Pyloquent
users = await User.where('active', True).order_by('name').get()
# With relationships โ€” SQLAlchemy
stmt = select(User).options(selectinload(User.posts)).where(User.id == user_id)

# Pyloquent
user = await User.with_('posts').find(user_id)
posts = user.posts  # already loaded
  • Familiar API โ€” If you know Laravel Eloquent, you know Pyloquent
  • Type Safe โ€” Full Pydantic v2 integration for validation and serialisation
  • Async Native โ€” Built for modern async Python (FastAPI, Starlette, etc.)
  • Production Ready โ€” 100% test coverage, comprehensive test suite
  • Framework Agnostic โ€” Works with FastAPI, Starlette, Litestar, or standalone
  • Cloud Native โ€” Cloudflare D1 support for edge deployments

Contributing

Contributions are welcome! Please see AGENTS.md for development guidelines.

License

Pyloquent is open-sourced software licensed under the MIT license.


Built with โค๏ธ for the Python community

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyloquent-0.2.0.tar.gz (151.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyloquent-0.2.0-py3-none-any.whl (109.6 kB view details)

Uploaded Python 3

File details

Details for the file pyloquent-0.2.0.tar.gz.

File metadata

  • Download URL: pyloquent-0.2.0.tar.gz
  • Upload date:
  • Size: 151.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for pyloquent-0.2.0.tar.gz
Algorithm Hash digest
SHA256 90aa2689a9808fd4a8e51d7e90b81e3d4281f627a5873fdbb691be42a6742a0d
MD5 c4fd46b4f01bcea708d4d026535752c8
BLAKE2b-256 9bf3d4b5aa280282da835a931f12d8bad86ce3109667fb1d92850fa5cc8f1c3b

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyloquent-0.2.0.tar.gz:

Publisher: publish.yml on avltree9798/pyloquent

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyloquent-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: pyloquent-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 109.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for pyloquent-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7b57b638c6c8756105f85e54515d0092e599ff4c802fdf58ede5dd24fb96afe5
MD5 95fd29aa078d24a40c4d67fa594e9edc
BLAKE2b-256 89159af5636a90bc3aac20d7cf8c04ef334d1510afa7963cd6df874f2da07cb5

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyloquent-0.2.0-py3-none-any.whl:

Publisher: publish.yml on avltree9798/pyloquent

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page