Post

Meet nORM. It is not an ORM.

First public release of nORM, a SQL-first code generator I have spent the last five months building.

Meet nORM. It is not an ORM.

I shipped nORM (no ORM) v0.1.0 this week. It is a SQL-first code generator for database access: schema and queries live in SQL files, norm generate produces typed Python repos and models. The CLI is on PyPI as norm-cli. MIT, free to use.

I have been working on it for about five months. The codebase is roughly 9k lines at this point, which is larger than I expected when I started.

Background

Most of my Python services end up in the same place. I start with SQLAlchemy or similar, ship a few features, then spend time fighting the ORM for anything that needs a careful query plan. I drop to raw SQL for those paths and suddenly I am maintaining mapping code by hand again.

I tried sqlc and liked the workflow a lot. SQL stays in version control, generated code gives you typed methods, and you stop rewriting the same row-to-model boilerplate. For a bunch of projects that was enough.

On others I kept reaching for ORM patterns anyway: list endpoints where the client picks filters, PATCH bodies where only some columns change, joins that should return a nested object when a row exists and None when it does not. With sqlc I still wrote extra Python to stitch that together. That gap is mostly why nORM exists.

The sqlc page in the docs says the same thing more plainly. sqlc is excellent. nORM is heavily inspired by it. If sqlc already covers your project, keep using sqlc. I am not trying to convince anyone to switch for sport.

Under the hood

Parsing and dialect work run through sqlglot. nORM builds on that to read schema DDL, repository queries, and macros, then reason about types and SQL shape before codegen. Multi-database support (Postgres, SQLite, MySQL, ClickHouse, DuckDB) depends on that layer translating and validating SQL consistently. I have contributed to sqlglot a few times along the way, which helped when debugging parser edge cases in nORM.

Everything else (CLI, generators, macros, config) is nORM itself. Paths for inputs and outputs are configured in norm.yaml, not hardcoded.

What the workflow looks like

norm init writes a config file and scaffolds empty schema and repository paths. You point norm.yaml at your schema SQL and at a folder of repository SQL files. Each query file uses comments to name the repo, each query, and whether it returns one row (:one), many (:many), or nothing (:exec).

1
2
3
4
schema.sql              →  table definitions (path from config)
repositories/*.sql      →  named queries
       norm generate
<your output path>      →  models + repo classes

Your app imports the generated package and passes a DB connection into the repo class. Change SQL, regenerate, fix whatever the type checker flags.

Example

From the Python tutorial, trimmed a bit:

Schema:

1
2
3
4
5
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name text NOT NULL,
    blocked bool DEFAULT false
);

Repository SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- repo_name: UsersRepo

-- name: get_user :one
SELECT * FROM users WHERE id = :id;

-- name: list_users :many
SELECT * FROM users ORDER BY name;

-- name: create_user :exec
INSERT INTO users (name, blocked) VALUES (:name, :blocked);

-- name: delete_user :exec
DELETE FROM users WHERE id = :id;

Usage:

1
2
3
4
5
6
7
from users_repo import UsersRepo, CreateUserParams

async with get_db() as db:
    repo = UsersRepo(db)
    user = await repo.get_user(id=42)
    users = await repo.list_users()
    await repo.create_user(CreateUserParams(name="Ada", blocked=False))

(users_repo lives wherever you set gen.out in norm.yaml.)

Generated methods contain the SQL they run. You can read the output file and know what hits the database.

Where nORM goes further than plain codegen

Runtime composition kept pulling me back toward ORM-style code. Search forms with optional filters. Tables with user-chosen sort columns. Updates that only touch fields the client sent. Joins that should hydrate a nested model when a related row exists.

nORM has macros and guides for those cases so the logic stays in SQL and the generator expands it. The overview lists dynamic filtering and sorting, partial updates, join embedding with n.nembed() for nullable left joins, and one :param style across the databases above. I use the guides when I need a feature; I am not summarizing them here.

0.1.0 scope

In the release: Python generator only. Async or sync repos, Pydantic or dataclasses, your choice in norm.yaml. CLI: norm init, norm generate, norm check (handy in CI), norm schema pull for Postgres introspection when you start from an existing database.

Out of scope for now: Rust, Go, and TypeScript generators are not built yet. Neither is a migrations command. Handle schema changes with whatever you already use. The docs mention migrations as a future direction; there is nothing to run today.

Expectations: v0.1.0 means I use it on my own projects and the core path works, but you should assume rough edges. File issues if something breaks.

Try it

1
2
3
4
pipx install norm-cli
norm init
# edit schema + repository SQL (paths from norm.yaml)
norm generate

Python 3.12+ for the CLI. The tutorial linked above is the best place to start.

If you try it and something feels off, open an issue. I read them.

This post is licensed under CC BY 4.0 by the author.

Trending Tags