Thank you for your comment Rafał Kondziela, your points are absolutely valid, and it also means I didn’t explain my position clearly enough. Let me expand on it.
ORM is not "bad". It’s a widely accepted but deeply wrong idea about how we should deal with a persistence layer.
Maintainability
You mentioned raw SQL being a nightmare in large projects. I’m not sure that's accurate unless we compare the actual maintenance cost.
If anything, certain patterns become far more painful with an ORM because you inevitably end up fighting against its default state tracking and session mechanics. You’re not simplifying your system, you’re wrestling with an abstraction that doesn’t want to let go.
As for schema changes: renaming a column used in dozens of queries usually looks as trivial as:
SELECT u.email AS superid
FROM users AS u
A project-wide search-and-replace like u.email → u.superid does the job, and your tests stay green. If they don’t — great, that’s exactly why tests exist.
Adding a column? You’d revisit queries, models and DTOs anyway. Add missing parts, amend tests or write new ones.
Migrations and validation
This heavily depends on the driver. For example, asyncpg handles booleans and other primitive types correctly out of the box. More complex types (like JSONB) require custom serialisation and that’s perfectly fine:
def orjson_default(obj: Any) -> Any:
if isinstance(obj, (pendulum.DateTime, pendulum.Date)):
return obj.isoformat()
if isinstance(obj, Path | Decimal | UUID):
return str(obj)
if isinstance(obj, set):
return list(obj)
raise TypeError(f"Type {type(obj)} is not serializable")
def dumps(obj: Any) -> str:
return orjson.dumps(obj, default=orjson_default).decode()
Exception handling
You can (and should) do exactly the same at the repository level:
async def _add(self, profile: Profile, connection: Connection) -> None:
query = """
INSERT INTO profiles (
id, public_id, email, nickname, state, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
"""
try:
await self.db.execute(query, [
profile.id,
profile.public_id,
profile.email,
profile.nickname,
profile.state,
profile.created_at,
profile.updated_at,
], connection)
except UniqueViolationError as exc:
raise ProfileUniqueViolationError from exc
Consistent domain model
When an ORM hides the persistence layer, you lose visibility into how data is actually stored, which makes debugging, analytics, and performance work harder.
With explicit factories, you control the mapping directly. They are the single source of truth for how database records become domain objects. In Hypha’s case, these factories are monadic, meaning they encapsulate failure and error-handling behaviour by design, so we can choose whether to propagate, wrap, or suppress errors explicitly. But it's completely out of scope now, and simplified example might look like this:
class ProfileFactories:
@classmethod
def profile_from_record(cls, rec: Record) -> Profile | None:
try:
return Profile(
id=UUID(str(rec["id"])),
public_id=HyphaID(rec["public_id"]),
email=rec["email"],
nickname=rec["nickname"],
state=ProfileState(rec["state"]),
created_at=pendulum.instance(rec["created_at"]),
updated_at=pendulum.instance(rec["updated_at"]),
)
except Exception:
return None
Hypha is a real-world demonstration that this approach actually works. With dozens of repositories and services relying on raw SQL, the codebase remains clean, straightforward, and thoroughly tested. The absence of an ORM didn’t make the system harder. It made it simpler, more predictable, and easier to reason about.
I fully agree that using Postgres’ native capabilities consciously gives a huge advantage in terms of performance when executing code. However, I don’t agree with the statement that ORM is “bad”. You just need to know how to use ORM properly. It’s like saying: why use a jackhammer if it costs electricity, when I can do the same manually with a hammer and a chisel?
Some things that I think are missing in this perspective:
maintainability – in a project with dozens of tables, the need to maintain raw SQL (whether as templates or – even worse – strings in Python) is a recipe for a nightmare. Not to mention SQL validation, a syntax checker or at least a formatter. Even the smallest schema change (e.g. column name) requires updating dozens of places in the codebase.
migrations and validation – this is another area we have to watch very carefully when a table schema changes, and manually handle additional serialization, eg. we cannot simply pass Python
Trueinto a boolean column, instead we must convert it into SQL literalTRUE.exception handling – ORM gives more semantic domain-specific errors instead of generic driver messages like:
ERROR: duplicate key value violates unique constraint.consistent domain model – without ORM, we still map records into objects, only that mapping happens… inside the developer’s head.
For me the right approach should rather look like: using ORM as the default tool for CRUD and the domain model, and raw SQL where it gives a real advantage (aggregations, optimization, reporting). But even in the second case, you can create advanced queries in a clean and efficient way, however, you must know and understand the specific ORM at an advanced level.