Why I Let the Database Own Its Own Schema

How ditching the ORM translation layer and letting PostgreSQL own its schema solved the drift problem for good.

Hashan Ranasinghe4 min read
Share

If you've read my previous article, you already know the root cause I ran into: treating the ORM as the owner of the database schema. If you haven't, I'd recommend starting there before continuing.

When you use an ORM, you define your tables in your project's language Python or TypeScript and the ORM translates that into SQL and creates the tables in the database. Naturally, you start treating the ORM as the source of truth.

But that translation process is lossy.

What you define in the ORM and what actually ends up in the database can be different things and the gap between them is easy to miss.


The Shift in Approach

The fix is simpler than you'd expect: let the database own its own schema by writing your tables in raw SQL.

No translation layer.
No silent omissions.

But before you close this tab, I hear you:

"Raw SQL? So now I have to manually track every single change forever?"

Not quite.


Enter pg_schemadiff

This is exactly where pg_schemadiff enters the picture.

It's the tool I now use to define and manage everything schemas, tables, indexes, relationships… the works.

What it does

pg_schemadiff computes the difference between two PostgreSQL schemas and generates the exact SQL needed to go from point A to point B.

It’s built with minimal downtime in mind, making it practical for both development and production deployments.

In short:

You define what you want the schema to look like → point the tool at your actual database → it tells you exactly what needs to change.


A Simple Example

Let’s make this concrete.

Say you created a users table with a plain UUID primary key, and later you want to switch to PostgreSQL’s built-in gen_random_uuid() for automatic ID generation.

With the SQL-as-source-of-truth approach, you simply update your schema file.

Before

CREATE TABLE IF NOT EXISTS auth.users (
    id UUID PRIMARY KEY,
    email VARCHAR(320) NOT NULL UNIQUE,
    full_name VARCHAR(255),
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
);
 

After

CREATE TABLE IF NOT EXISTS auth.users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(320) NOT NULL UNIQUE,
    full_name VARCHAR(255),
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
);
 

That one-line change is all you touch.

pg_schemadiff then:

  • Compares your updated schema file with the actual database

  • Detects the difference

  • Generates the exact ALTER TABLE command needed

No inference.
No silent skips.
Just an explicit, reviewable migration.


Wrapping Up

By now, you should have a clear picture of what pg_schemadiff does—and why this approach works.

In the next article, we’ll go hands-on:

  • Real commands

  • Real workflow

  • A practical walkthrough in an actual project