Skip to content

INW016 orm-naming

What it does

Opt-in, and an error when on. Holds the names of database tables and of datetime and date columns to one scheme. By default that is the scheme fastapi-best-practices sets: table names in lower_case_snake and in the singular (post, post_like), _at at the end of a datetime column (created_at) and _date at the end of a date column (published_date). One finding per name that breaks it, on the name, with the name to use instead.

What it reads:

Construct Table name Datetime and date columns
SQLAlchemy declarative __tablename__ = "post" Mapped[datetime], Mapped[date] (also inside Optional[...] or ... \| None); mapped_column(DateTime), Column(Date), and TIMESTAMP, DATETIME, DATE
SQLAlchemy Core Table("post", metadata, ...) Column("created_at", DateTime)
SQLModel __tablename__, or the name SQLModel derives for a table=True class without one: the class name, lower-cased (Heroes gives heroes) created_at: datetime and published_date: date fields of a table=True class
Django class Meta: db_table = "post" models.DateTimeField(), models.DateField()

A column's name is the one the database gets: the string a call passes (mapped_column("created_at", DateTime), db_column="created_at"), else the attribute's. An explicit type wins over the annotation: Mapped[datetime] = mapped_column(Date) is a date column.

A whole-project run (inwards check without paths, or the Stop gate with stop-gate = "project") also reports one finding when the project has SQLAlchemy or SQLModel tables and no MetaData(naming_convention=...) anywhere: on the first table in the files the rule covers. An assignment to <something>.naming_convention, such as SQLModel.metadata.naming_convention = {...}, counts too. Django tables don't take part: Django names its constraints itself.

A project with another scheme

A whole-project run also notices a project that follows another scheme on purpose. When at least 5 names are checked and 90% or more of them break the configured scheme the same way, it reports one finding for them, on the first such name, instead of one finding per name. The finding says what the project does and which option value matches it:

What the project does The finding asks for
90% of its lower_case_snake table names read as plural, under table-name = "snake_singular" table-name = "snake"
90% of its datetime (or date) column names end with one other time suffix, such as _on datetime-suffix = "_on" (or date-suffix)
90% of its datetime (or date) column names miss the suffix, with no one suffix shared datetime-suffix = false (or date-suffix)

The names that follow the configured scheme anyway (a singular table in a plural project) aren't reported. Neither is the scheme when fewer than 90% of the names follow it: a project halfway through a rename gets a finding per name, so it can see what is left. A name that breaks the scheme another way, such as a table name that isn't lower_case_snake, keeps its own finding.

A check of some paths, the per-edit hook and the editor see one file or a few, not the project's habit, so they report each name against the configured scheme, the default included. Reading every model on each edit to learn the habit would make an edit's result depend on files it didn't touch, and cost a parse of every model per edit. The project-wide finding names the option instead: once the user sets it, the per-edit checks agree with the project. Until then, a plural table's per-name finding mentions table-name = "snake" in its fix.

Files are checked whatever layer they are in, apart from test modules (check-tests = true brings them back, options tables); modules narrows the rule to some of them, such as the models packages. The names a project-wide finding counts come from the same files, so a test's models don't count toward the 90%.

Why is this bad

A schema lives longer than the code that first wrote it, and every query, migration, report and dashboard repeats its names. Mixed names (posts next to post_like, created next to updated_at, start_date holding a timestamp) make every reader stop and check, and a renamed table or column later needs a migration and a change in every place that uses it. Agents copy whatever the model next to them does, or what the tutorial they remember does, so a project drifts one model at a time. The suffixes also carry the type: _at says "a point in time, with a time zone question to ask", _date says "a calendar day".

SQLAlchemy names an index, but not a unique, check, foreign key or primary key constraint, unless the MetaData has a naming convention: "neither Alembic nor SQLAlchemy currently create names for constraint objects where the name is otherwise unspecified" (SQLAlchemy: Configuring Constraint Naming Conventions). The database then picks the names, and a later migration that drops or alters such a constraint has to guess them. fastapi-best-practices sets the convention for that reason (Set DB keys naming conventions).

No other linter reads SQLAlchemy's table and column semantics for names: flake8-sqlalchemy checks other things, and Ruff's naming rules look at Python identifiers, not at the strings a table is named with.

Example

pyproject.toml
[tool.inwards]
layers = [
  { name = "db", modules = ["blog.db"] },
]

[tool.inwards.rules]
extend-select = ["INW016"]
blog/db/models.py
from datetime import datetime

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    created: Mapped[datetime]
inwards check blog
blog/db/models.py:11:21: INW016 Table name "posts" is plural; this project names tables in the singular: "post".
  fix: Rename the table "posts" to "post".
    1. Change the name where it is written to "post": `__tablename__`, the first argument of `Table(...)`, or `db_table`.
    2. If the table already exists, add a migration that renames it, such as Alembic's `op.rename_table("posts", "post")`, and update raw SQL that names it.
    3. If "posts" is singular in fact (a mass noun such as "news"), ask the user to add it to `allow-tables` in [tool.inwards.rules.orm-naming]; if the project names all its tables in the plural, ask for `table-name = "snake"` there instead. Don't edit [tool.inwards] yourself.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW016/

blog/db/models.py:15:5: INW016 Datetime column `created` doesn't end with `_at`: name it `created_at`.
  fix: Rename the column `created` to `created_at`.

A whole-project run adds the missing naming convention, on the first table:

blog/db/models.py:11:21: INW016 No `MetaData(naming_convention=...)` in the project, so SQLAlchemy leaves unique, check, foreign key and primary key constraints unnamed and the database picks their names.
  fix: Give the declarative base's MetaData a naming convention.

In a project whose tables are all plural, such as Polar's 98 of 104, a whole-project run reports them once, on the first, instead of the plural finding on each:

server/polar/models/account.py:35:21: INW016 This project names its tables in the plural, which the singular scheme reports on every table: `table-name = "snake"` matches it.
  fix: Ask the user to set `table-name = "snake"` in [tool.inwards.rules.orm-naming], or rename the tables to the singular.

Fixed: a singular table, created_at, and a naming convention on the declarative base. The whole project passes:

blog/db/models.py
from datetime import datetime

from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

POSTGRES_INDEXES_NAMING_CONVENTION = {
    "ix": "%(column_0_label)s_idx",
    "uq": "%(table_name)s_%(column_0_name)s_key",
    "ck": "%(table_name)s_%(constraint_name)s_check",
    "fk": "%(table_name)s_%(column_0_name)s_fkey",
    "pk": "%(table_name)s_pkey",
}


class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)


class Post(Base):
    __tablename__ = "post"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    created_at: Mapped[datetime]
inwards check

How it works

A text pre-filter runs first: a file that names none of __tablename__, Table(, table=True, Mapped, Column(, mapped_column, db_table, DateTimeField and DateField is never parsed. A file that passes gets one parse of its own.

  • Names are qualified through the file's imports, so import sqlalchemy as sa and sa.DateTime(timezone=True) read as SQLAlchemy's DateTime, and from datetime import datetime as dt and Mapped[dt] as a datetime. A Table, Column, mapped_column, MetaData or column type counts when it comes from sqlalchemy or sqlmodel; a Django field when it comes from django.db.models.
  • Table names must be string literals or same-file constants; a name built at run time, such as a declared_attr function, isn't read.
  • lower_case_snake is lower-case words of letters and digits joined by single underscores: post, post_like, oauth2_token. The suggestion splits camel case and replaces other characters: UserAccount gives user_account.
  • Singular is a heuristic on the last word of the name. A word ending in s reads as plural unless it ends in ss, us, is or ics (address, status, analysis, analytics), is three letters or fewer, or is one of a few words that are the same in both numbers (news, series, species, alias). A few plurals without an s (people, children, men) count too. The suggestion undoes -ies, -es after a sibilant and -s: categories gives category, addresses gives address, post_likes gives post_like.
  • The suffix suggestion swaps another time suffix the name ends with (_date, _time, _on, _ts, _dt, _datetime, _timestamp, _at) for the configured one, moves a leading date_, date_of_, time_ or timestamp_ to the end as the suffix, or adds it: due_date holding a datetime gives due_at, date_joined gives joined_at, date_of_birth holding a date gives birth_date, created gives created_at. It never repeats a word: a name that is only time words (date, timestamp, date_time) gets no suggestion, and the finding asks for a name that says what the time records; so does a name that still holds a word of the suffix, such as start_date_utc under _date.
  • The project's scheme is decided from every table and column the whole-project run read in files the rule covers. It reuses what the per-file checks read, so it parses nothing more: on Polar the run took about 25 ms longer (median of 7 runs, 674 ms against 649 ms).
  • The naming convention is searched in every file of the run, whether the rule covers it or not, so a convention in app/database.py counts for models in app/*/models.py that modules selects. A file is parsed for it only when its text says naming_convention.

How to fix

  1. Rename the table or column where it is declared, and every place that uses the old name.
  2. If the table or column already exists, add a migration that renames it: Alembic's op.rename_table("posts", "post") or op.alter_column("post", "created", new_column_name="created_at").
  3. For the naming convention, give the MetaData every model shares a naming_convention, then generate a migration: existing constraints keep their names until it renames them.

Fix safety

No automatic fix (autofix: false). A rename changes the database schema: it needs a migration, and every query, serializer, fixture and report that names the table or column changes with it.

Configuration

Severity error. Opt-in: turn it on with extend-select. The options live in [tool.inwards.rules.orm-naming] (options tables):

pyproject.toml
[tool.inwards.rules]
extend-select = ["INW016"]

[tool.inwards.rules.orm-naming]
modules = ["shop.*.models"]          # optional: only these modules
table-name = "snake_singular"        # or "snake", or false
datetime-suffix = "_at"              # or false
date-suffix = "_date"                # or false
allow-tables = ["user_settings"]     # names accepted as written
require-naming-convention = true     # whole-project runs only
  • table-name: "snake_singular" (the default) wants lower_case_snake names in the singular, "snake" lower_case_snake only, and false turns the table check off.
  • datetime-suffix, date-suffix: the suffix each datetime or date column name ends with, lower-case letters, digits and underscores; false turns that check off. The defaults are "_at" and "_date".
  • allow-tables: table names the rule accepts as they are written, such as a mass noun the singular heuristic takes for a plural, or a legacy table you can't rename yet.
  • require-naming-convention: false drops the project-wide finding for a missing MetaData(naming_convention=...).

A single name can be suppressed inline on its line: # inwards: ignore[INW016] reason="...". The project-wide findings aren't tied to a line they own, so they can't be:

  • The naming convention finding goes away with require-naming-convention = false.
  • A scheme finding goes away with the option value it names, which also tells the per-edit checks about the scheme. inwards baseline accepts it too: its message has no counts or examples, so it stays accepted while tables and columns are added, as long as the first name it sits on stays in the same module. The counts and examples are in its fix.
  • A comment on a name the scheme finding replaces has nothing left to hide in a whole-project run.

A template can turn INW016 on for one of its roles: models = { orm-naming = true } sets modules to the role's modules. inwards init --style fastapi writes models = { orm-naming = "warning" } into its fastapi-domain template, so every domain's models follow this scheme from the start (install guide).

Known limitations

  • English only. The singular heuristic knows English plurals, and not all of them: caches gives cach, and settings reads as plural. Add such names to allow-tables.
  • Only the last word of a table name is checked for the plural: posts_like passes.
  • Derived names. A table name from a declared_attr, a mixin's __tablename__ function or a string built at run time isn't read, and neither is Django's default <app>_<model> name, which needs the app label.
  • Types the rule can't see. A column whose type comes from a type_annotation_map entry, a custom TypeDecorator, a type alias imported from another module, or SQLModel's sa_column=Column(DateTime) without a name isn't read as a datetime or date.
  • The naming convention is looked for in whole-project runs only: a check of some paths, the per-edit hook and the editor don't report it. A convention set in a way the rule doesn't read (a MetaData passed **kwargs, a convention in a library) gives a false finding; set require-naming-convention = false.
  • Alembic migrations aren't read. A file that imports alembic records the schema's history: the rename belongs in the model and a new migration, and the model's own finding asks for it.
  • The 90% threshold is a guess fitted to one project: Polar's tables are 94% plural. A project at 85% gets a finding per name.
  • Measured on the corpus (#182, chapter 6) with the defaults: 173 findings before the migration rule, 152 after (121 in Polar, 26 in Saleor), every column finding and every table name looked at; all 152 break the default scheme as reported. With the project-wide scheme finding (#361), Polar's 98 plural table names became one finding: 121 → 24. Saleor stays at 26: 46 of its 72 datetime columns end with _at, so its date_joined and start_date are a mix, not a scheme. 13 of the 50 column findings left no longer suggest a name made of time words only (date_at, timestamp_at), and date_of_birth gets birth_date.

References