Lindsay Edwards

One ORM, any database is a lie

On this page

The bug that finally broke my faith in “one ORM, any database” turned up while building IntoAction, a relationship CRM I’ve been working on. A JSON column worked perfectly on my machine and returned nonsense in production.

Same code. Same query. Same ORM. Different driver underneath, and that turned out to matter far more than the marketing suggested.

The column that changed shape#

IntoAction runs SQLite in development and Postgres in production, which is a common enough setup. Drizzle’s pitch is that it hides the dialect so you write your queries once and forget which engine is underneath.

That holds right up until you read a value back. I had a column storing a small blob of JSON. On SQLite the driver handed it back to me already parsed into an object. On Postgres the same column came back as a raw string. My mapping code assumed the object, so in production it was trying to read properties off a string and getting undefined everywhere. For anyone actually using the product at that moment, it meant a feature that worked perfectly right up until the one moment it needed to work for real, in front of a real user.

It was not just JSON. Timestamps came back as one type on one driver and another type on the other. Booleans were worse, because SQLite has no real boolean, so a true could arrive as the number 1 while Postgres gave me an actual boolean.

The ORM was not lying about the SQL it generated. It was lying about the runtime types coming out the other side.

An abstraction that promises two databases are identical is only telling you about the query going in. It says nothing about the shape of the value coming out.

Making the dialect explicit again#

My first instinct was to sprinkle in checks. If it is a string, parse it. If it is a number, coerce it. That is how you end up with defensive code in forty places and a new bug every time you add a column.

The fix that actually worked was the opposite of hiding the dialect. I made it explicit. I wrote parallel schema definitions, one per backend, and a tiny runtime selector that picked the right one based on configuration.

The important rule was that services never imported a database client directly. They imported the selector. That gave me exactly one place where the backend was decided, instead of the decision leaking into every caller.

Then I did the normalising myself. My mapping layer took whatever the driver handed back and converted it into one shape before anything downstream saw it. JSON always arrived parsed. Dates always arrived as real dates. Booleans were always booleans.

// the boundary owns the coercion, not the caller
function readRow(raw) {
return {
payload: parseJson(raw.payload), // string or object in, object out
createdAt: toDate(raw.created_at), // driver type in, Date out
isActive: toBool(raw.is_active), // 1 / true / "t" in, boolean out
};
}

Every caller now got the same object regardless of which engine served it. The tests in development finally meant something about production, because the value they checked was the value production would produce.

The takeaway#

“One ORM, any database” is a leaky abstraction, and the leak is at the type coercion layer, not the SQL. The query is the easy part. The values coming back are where the drivers quietly disagree.

Be explicit about the dialect instead of pretending it does not exist, keep the choice in one selector rather than scattered through your services, and normalise JSON, dates, and booleans at your own boundary so every caller sees one shape.

I still use the ORM. I just stopped trusting it to tell me what type I was holding.

Keep reading