Skip to main content

PG.L8.NAMING_SNAKE_CASE — An identifier PostgreSQL will not hand back the way you wrote it

  • Category: convention
  • Level: 8
  • Confidence: deterministic
  • Downtime class: none
  • Stability: stable
  • Suites: lint, audit
  • Applies to: PostgreSQL 18 and newer

This is a case rule, not a taste rule

Level 8 is where conventions live, and most of what belongs there is preference. This one is not.

PostgreSQL folds an unquoted identifier to lower case. So these two statements do not create the same table:

CREATE TABLE "Orders" (id bigint); -- a table named Orders
CREATE TABLE Orders (id bigint); -- a table named orders

Afterwards SELECT * FROM Orders reaches the second and never the first, because the reference is folded too. A schema that ended up with both has two tables where every reader believes there is one — and the one they cannot reach is the one holding the rows.

The same applies to columns, indexes, constraints, views and sequences. A column created as "createdAt" is only ever reachable as "createdAt", quotes included, in every query and every migration for the rest of its life.

Flagged

Schema::create('Orders', function (Blueprint $table) {
$table->id();
$table->timestamp('createdAt');
});

Preferred

Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->timestamp('created_at');
});

What it reports and what it does not

It judges the name of a table, column, index, constraint, view, materialized view or sequence. It does not judge roles, grants, settings or extensions: those are not names the project chose.

It also does not filter catalog noise itself. Extension-owned objects, partitions and the Laravel table prefix are handled once, centrally, by the catalog reader — a second copy of that logic here would drift from the first, and the drift would show up as findings on objects nobody owns.

Both before and after

The rule runs in lint against the migration that introduces the name, and in audit against the catalog. That is deliberate: lint catches it while it is still a diff and costs nothing to change, audit catches the one that arrived some other way — a hotfix, an older migration, a table made by hand.

A migration that introduces several such names reports them together in one finding rather than one at a time. A finding is located at a statement, so a second finding for the same statement would be deduplicated away by rule id and location, and the extra names would vanish silently.

Sources